mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* 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>
180 lines
7.0 KiB
Rust
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)],
|
|
]);
|
|
}
|
|
}
|
|
}
|