Files
orca/native/windows-agent-hook/src/main.rs
T
OrcaWinandOrca Worker 7909732283 perf(claude): run the Windows Claude hook as a signed native claude-hook.exe (#27719)
* perf(claude): run the Windows Claude hook as a native orca-hook.exe

Each Claude hook event on Windows started cmd.exe (plus the user's cmd
AutoRun) and then curl.exe on top of the agent's own shell. A small signed
Rust binary now does the same synchronous POST in one process, under Git
Bash, PowerShell and direct CreateProcess alike.

- native/windows-agent-hook: std-only crate, same contract as the .cmd hook
  (prints {}, env guards before stdin, endpoint re-read, 0.5s/1.5s caps,
  waits for the listener reply, always exit 0). Its installed file name
  selects the agent profile, so the registration stays a bare path.
- Packaged as resources/bin/orca-hook.exe (SignPath signs it with the other
  inner PEs); afterPack fails on a missing or wrong-machine binary.
- Claude install copies it verbatim to ~/.orca/agent-hooks/claude-hook.exe
  (temp + rename, running copy moved aside) and registers it; it falls back
  to the .cmd hook when the bundle is missing, unsigned or cannot be copied.
  Only Claude opts in, via windowsNativeHookExecutable.
- The managed-command matcher sweeps .exe entries alongside .cmd/.ps1/.sh.
- The CLI launcher and hook builds share windows-rust-executable-build.mjs,
  which now also checks the PE machine.

Refs #20913

* fix(build): publish native Windows binaries atomically so parallel builds cannot collide

The Windows CI lane runs every orca-hook.exe suite in one vitest
invocation, each building through build-windows-agent-hook.mjs. The build
copied straight onto native/windows-agent-hook/.build/orca-hook.exe, and
CopyFile opens its destination unshared, so a second build (or a scanner
still holding the fresh image) failed with EBUSY.

- windows-rust-executable-build.mjs skips the publish when the output
  already has the built bytes, otherwise stages a per-process temp file and
  renames it over the output, retrying briefly while another handle holds it.
- The test fixture hands each suite a private copy of the build output, so
  no suite reads or starts the shared file while another build replaces it.

Refs #20913

* refactor(claude): simplify the native Windows hook to one Claude binary

Review follow-up on the native hook (#20913): the same behaviour with less
machinery.

- The binary is built, shipped and installed as claude-hook.exe, so the
  installed name matches its PE OriginalFilename. It is Claude-only, so the
  file-name profile table and the per-agent opt-in flag are gone; the
  registration is gated by the existing Claude-only usesWindowsEntry check.
- Install and refresh reuse writeManagedScript / refreshManagedScriptIfPresent
  (now accepting bytes): temp file, rename, skip when identical. A copy whose
  hook is still running cannot be replaced; install then registers the .cmd
  hook and refresh keeps the old build until a later start, instead of the
  rename-aside and *.retired sweep.
- The install-time Authenticode-presence check is dropped. Release CI now
  requires resources\bin\claude-hook.exe to be signed (release-cut and the
  signing rehearsal), next to resources\bin\orca.exe.
- getStatus counts either Windows form through findManagedEventHookForms in
  hook-settings.ts, replacing managed-event-hook-status.ts.
- Rust: plain Claude constants, one endpoint-file lookup (last assignment
  wins, as under `call`), one request write and a deadline-bounded drain.
  Guard order and form encoding are unchanged.
- Build: PR CI builds the hook once before the Windows tests, so the suites
  only read it; the launcher publish goes back to copyFileSync and the retry
  loop and its test are gone. The PE-machine check lives only in afterPack.
  Cargo.lock joins both crates' build fingerprints, and the launcher's alias
  exports are removed.

Refs #20913

* test(ci): run the managed hook stdin-lifecycle suite in the Windows lane

Its claude-hook.exe case (and the other Windows-only cases) are gated per
test, so the lane-registration guard did not require the file and it ran
only on Linux, where those cases skip. Register it in both Windows lists.

Refs #20913

* feat(agent-hooks): ship the native hook as orca-agent-hook.exe <agent>

The native Windows hook (#20913) becomes one general binary that stays
Claude-only in behaviour.

- Named orca-agent-hook.exe everywhere: build output, PE OriginalFilename
  and InternalName, resources\bin, the afterPack check, the installed copy
  in ~/.orca/agent-hooks, and the release signed-target lists.
- It takes the agent as its first argument and Claude registers
  `<path> claude`. The CLAUDE_JOB_DIR exit and the Grok/Devin drain belong
  to that agent; an unknown or missing agent answers {} without reading
  stdin. The managed-command matcher owns `orca-agent-hook.exe claude` and
  still owns the old claude-hook.exe entry, so installs migrate; install
  deletes a leftover claude-hook.exe when nothing runs it.
- A copy still running from the previous build no longer forces the .cmd
  fallback. Identical bytes are not rewritten; otherwise the temp file is
  renamed over the target, and when Windows refuses because the image is
  mapped, the running copy is renamed to orca-agent-hook.exe.old first.
  Install deletes a stale .old; only when that copy is also still running
  does the install fall back to .cmd. writeManagedScript and
  refreshManagedScriptIfPresent take the aside path, sharing one helper.

Refs #20913

* fix(agent-hooks): roll back a failed aside rename and keep an installed hook exe

Design-review follow-up on the native hook (#20913).

- If the running copy was moved to orca-agent-hook.exe.old but the new
  file then could not take its name, the copy is renamed back before the
  error is rethrown, so settings.json never points at a missing exe. The
  rename helper now owns the `.old` name, next to the comment that
  explains it; a mocked-fs unit test covers both the sync and async paths.
- A failed write, or a build with no bundled exe, keeps registering a copy
  that is already installed (every build knows `claude`), so a dev checkout
  and an installed build no longer flip settings.json between forms.
- The claude-hook.exe migration is gone: no release ever shipped that
  name, so the legacy sweep, the `.exe` matcher needle and their test are
  removed.

Refs #20913

* fix(agent-hooks): derive the native hook's agent with win32 path rules

getWindowsNativeHookAgent took the host's basename of the managed script
path, so on a POSIX host (Linux CI) a Windows path was not split and the
registered command became `orca-agent-hook.exe C:\...\claude`. It now takes
the path and applies path.win32.basename itself. The install destination
and the `.old` aside stay on host paths: they name files the host opens.

Refs #20913

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-12 16:09:30 -07:00

180 lines
7.0 KiB
Rust

//! Orca's native Windows agent hook, registered as `orca-agent-hook.exe <agent>`: answers
//! `{}`, then posts the hook payload to Orca's loopback listener and waits for the reply,
//! so status events arrive in the order the agent emitted them.
//!
//! Why native: the `.cmd` form costs a cmd.exe start (plus the user's AutoRun) and
//! a curl.exe start on every event, and only an executable runs the same way under
//! Git Bash, PowerShell, and a direct CreateProcess (#20913). The `claude` contract
//! mirrors the managed Windows script in src/main/claude/hook-script.ts.
use std::env;
use std::fs::File;
use std::io::{self, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::time::{Duration, Instant};
// Same caps as the curl form's `--connect-timeout 0.5 --max-time 1.5`.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
const TOTAL_TIMEOUT: Duration = Duration::from_millis(1500);
const ENDPOINT_FILE_BYTE_LIMIT: u64 = 64 * 1024;
/// What one agent's hooks report under, and which of its env vars veto reporting.
struct Agent {
source: &'static str,
/// Set means the event is not this pane's to report: exit without owning stdin.
exit_vars: &'static [&'static str],
/// Set means another agent imported these hooks: drain stdin and stay silent.
drain_vars: &'static [&'static str],
}
fn agent(name: &str) -> Option<Agent> {
match name {
"claude" => Some(Agent {
source: "claude",
// A backgrounded session inherited the dispatching pane's env (#9236).
exit_vars: &["CLAUDE_JOB_DIR"],
// Grok and Devin import .claude hooks; only their own hooks report those events.
drain_vars: &["GROK_HOOK_EVENT", "DEVIN_PROJECT_DIR"],
}),
_ => None,
}
}
fn main() {
let mut stdout = io::stdout();
// Why first: Claude-compatible permission hooks fail closed on empty stdout (#14818).
let _ = stdout.write_all(b"{}\r\n");
let _ = stdout.flush();
// Why no stdin for an unknown agent: a newer Orca may register one this copy predates.
if let Some(agent) = env::args_os().nth(1).and_then(|arg| agent(arg.to_str()?)) {
run(&agent);
}
// Why always 0: a hook failure must never block or fail the agent's tool call.
std::process::exit(0);
}
fn run(agent: &Agent) {
// Why: refresh the listener coordinates for terminals that survived an Orca restart.
let endpoint = read_endpoint_file();
let coordinate = |key: &str| endpoint_value(&endpoint, key).unwrap_or_else(|| var(key));
let port = coordinate("ORCA_AGENT_HOOK_PORT");
let token = coordinate("ORCA_AGENT_HOOK_TOKEN");
// Why (#11549): without Orca's env the hook ran outside an Orca pane, where the caller
// may abandon stdin; exit before reading it.
if port.is_empty() || token.is_empty() || var("ORCA_PANE_KEY").is_empty() {
return;
}
let is_set = |name: &&str| !var(name).is_empty();
if agent.exit_vars.iter().any(is_set) {
return;
}
let mut stdin = io::stdin().lock();
if agent.drain_vars.iter().any(is_set) {
let _ = io::copy(&mut stdin, &mut io::sink());
return;
}
let mut payload = Vec::new();
let _ = stdin.read_to_end(&mut payload);
let form = [
("paneKey", var("ORCA_PANE_KEY")),
("tabId", var("ORCA_TAB_ID")),
("launchToken", var("ORCA_AGENT_LAUNCH_TOKEN")),
("worktreeId", var("ORCA_WORKTREE_ID")),
("env", coordinate("ORCA_AGENT_HOOK_ENV")),
("version", coordinate("ORCA_AGENT_HOOK_VERSION")),
];
post(agent.source, &port, &token, &form, &payload);
}
fn var(name: &str) -> String {
env::var_os(name)
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// The file src/shared/agent-hook-listener/endpoint-publication.ts writes, or "" if unreadable.
fn read_endpoint_file() -> String {
let mut text = String::new();
let read = File::open(var("ORCA_AGENT_HOOK_ENDPOINT")).and_then(|file| {
file.take(ENDPOINT_FILE_BYTE_LIMIT)
.read_to_string(&mut text)
});
if read.is_err() {
text.clear();
}
text
}
/// The last `set KEY=VALUE` for `key`, as `call` would leave it; `lines` strips the CRLF.
fn endpoint_value(text: &str, key: &str) -> Option<String> {
text.lines().rev().find_map(|line| {
let assignment = line
.get(..4)
.filter(|prefix| prefix.eq_ignore_ascii_case("set "))
.and(line.get(4..))?;
let (name, value) = assignment.split_once('=')?;
(name == key).then(|| value.to_string())
})
}
fn post(source: &str, port: &str, token: &str, form: &[(&str, String)], payload: &[u8]) {
let Ok(port) = port.parse::<u16>() else {
return;
};
// A header value cannot carry control characters; such a token never authenticates anyway.
if port == 0 || token.bytes().any(|byte| byte < 0x20 || byte == 0x7f) {
return;
}
let mut body = Vec::with_capacity(payload.len() * 3 / 2 + 512);
for (name, value) in form {
append_form_field(&mut body, name, value.as_bytes());
body.push(b'&');
}
append_form_field(&mut body, "payload", payload);
let mut request = format!(
"POST /hook/{source} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Content-Type: application/x-www-form-urlencoded\r\n\
X-Orca-Agent-Hook-Token: {token}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
request.extend_from_slice(&body);
let deadline = Instant::now() + TOTAL_TIMEOUT;
let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let Ok(mut stream) = TcpStream::connect_timeout(&address, CONNECT_TIMEOUT) else {
return;
};
// Why wait for the reply: the listener applies the event before answering, and an exit
// before that would let the agent's next event overtake this one.
if arm(&stream, deadline) && stream.write_all(&request).is_ok() && arm(&stream, deadline) {
let _ = io::copy(&mut stream, &mut io::sink());
}
}
/// Bounds the next socket calls by what is left of the deadline; false once it has passed.
fn arm(stream: &TcpStream, deadline: Instant) -> bool {
let remaining = deadline.saturating_duration_since(Instant::now());
!remaining.is_zero()
&& stream.set_read_timeout(Some(remaining)).is_ok()
&& stream.set_write_timeout(Some(remaining)).is_ok()
}
/// `name=value` with the value percent-encoded exactly as curl's `--data-urlencode` does.
fn append_form_field(body: &mut Vec<u8>, name: &str, value: &[u8]) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
body.extend_from_slice(name.as_bytes());
body.push(b'=');
for &byte in value {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
body.push(byte);
} else {
body.extend_from_slice(&[
b'%',
HEX[usize::from(byte >> 4)],
HEX[usize::from(byte & 15)],
]);
}
}
}