orcareplay 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # OrcaReplay
2
+
3
+ ### Your agent broke something at 2am. Replay it at 9am — exactly, offline, as many times as you like.
4
+
5
+ Record any coding agent. Reproduce the run byte-for-byte with the network off. Fork it from any
6
+ step onto a different model and see who gets it right.
7
+
8
+ <a href="https://www.orcarouter.ai">
9
+ <img src="https://raw.githubusercontent.com/Continuum-AI-Corp/OrcaReplay/main/docs/orcarouter.svg" alt="OrcaRouter" height="28" align="left" hspace="10">
10
+ </a>
11
+
12
+ **Built by the team behind [OrcaRouter](https://www.orcarouter.ai)** — one API key and one endpoint
13
+ for Claude, GPT, Gemini, Grok, DeepSeek, Qwen and the rest. It is what `orca setup` points at by
14
+ default, and what makes `orca compare` a single command instead of four provider accounts.
15
+
16
+ [All models](https://www.orcarouter.ai/models) · [OrcaCode Review](https://www.orcarouter.ai/code-review) · [X](https://x.com/OrcaRouter) · [Hugging Face](https://huggingface.co/orcarouter)
17
+
18
+ <br clear="left">
19
+
20
+ ![Recording a Claude Code run, replaying it offline, then forking it onto two models](https://raw.githubusercontent.com/Continuum-AI-Corp/OrcaReplay/main/docs/demo-cli.gif)
21
+
22
+ <sup>Real output from one session — a Claude Code run recorded, replayed with the network off, then
23
+ forked at checkpoint 4 onto two models and graded by `npx tsc --noEmit`. Nothing here is mocked
24
+ up.</sup>
25
+
26
+ ## Install
27
+
28
+ ```console
29
+ npm i -g orcareplay
30
+ orca doctor # checks this machine can record at all
31
+ ```
32
+
33
+ Node 20 or newer. No native dependencies — nothing to compile, nothing to download at install time.
34
+
35
+ ## Three commands
36
+
37
+ ```console
38
+ orca record claude # your agent, unmodified, doing whatever it does
39
+ orca replay last # the same run again — no network, no tokens, no charge
40
+ orca replay last --from 4 --model claude-haiku-4-5 --ui
41
+ ```
42
+
43
+ The third line is the one people stay for: same files, same conversation prefix, different model
44
+ from step 4 onward. The model is the only variable, which is what makes the answer mean anything.
45
+
46
+ ## What it gives you
47
+
48
+ **A timeline of what actually happened.** Every model turn with its token counts and stop reason,
49
+ every tool call with its arguments and result, every shell command with its exit code, every file
50
+ the run touched.
51
+
52
+ ```console
53
+ $ orca show last
54
+ SEQ KIND WHAT DETAIL
55
+ 12 TOOL edit_file
56
+ 14 FILE src/auth.ts modified +18 −4
57
+ 15 SHELL ["npm","test","--","auth"] /home/dev/api
58
+ 16 SHELL shell result exit 1 · 8.4s
59
+ 17 ERROR error
60
+ ```
61
+
62
+ **What caused what.** Every edge says whether the recorder watched it happen or orca derived it
63
+ just now, and names the rule either way.
64
+
65
+ ```console
66
+ $ orca graph last --to 17
67
+ FROM TO KIND WHY
68
+ 12 tool.call 15 shell.exec recorded causes
69
+ 15 shell.exec 16 shell.result recorded shell result answers its exec
70
+ 16 shell.result 17 error recorded causes
71
+ ```
72
+
73
+ **The same task on several models, graded by a command you choose.** Not a model marking its own
74
+ homework — the verdict is the exit code of whatever you tell it to run, so the same prompt costs
75
+ you a number you can act on rather than an opinion.
76
+
77
+ ```console
78
+ $ orca compare last --from 4 --models claude-sonnet-5,claude-haiku-4-5 --verify "npx tsc --noEmit"
79
+ MODEL VERDICT TOKENS COST WALL
80
+ claude-sonnet-5 pass 124/429 $0.006807 12.2s
81
+ claude-haiku-4-5-20251001 pass 184/650 $0.003434 14.3s
82
+ ```
83
+
84
+ Both passed here; the interesting column is what each one cost to get there.
85
+
86
+ Plus a browser timeline (`orca ui`), a single self-contained HTML file you can attach to an issue
87
+ (`orca export`), and shareable cards of one causal chain (`orca export --card`).
88
+
89
+ ## Which agents
90
+
91
+ Claude Code, Codex, OpenCode, grok-cli, the Anthropic and OpenAI Agents SDKs, the Vercel AI SDK,
92
+ and any harness that reads a base-URL environment variable — including ones this project has never
93
+ heard of, via `ORCA_BASE_URL_VARS`. Harnesses that refuse to be redirected can be captured through
94
+ TLS interception with a certificate authority minted for that one run and thrown away after it.
95
+
96
+ `orca doctor` reports which of them it can find on your machine.
97
+
98
+ ## Full documentation
99
+
100
+ **[github.com/Continuum-AI-Corp/OrcaReplay](https://github.com/Continuum-AI-Corp/OrcaReplay)** —
101
+ the worked bug hunt, the trace format spec, the programmatic API, CI usage, what is stored and
102
+ where, and the privacy model.
103
+
104
+ Apache-2.0. The trace format spec is CC BY 4.0, so anything can read or write these traces.
@@ -0,0 +1,48 @@
1
+ export interface AdvertiseRequest {
2
+ /** The address the proxy is listening on. */
3
+ bind: string;
4
+ /** The port it actually bound, which is not the advertised one behind a port mapping. */
5
+ port: number;
6
+ /** An explicit host, optionally with its own `:port`, overriding both of the above. */
7
+ advertise?: string;
8
+ }
9
+ /**
10
+ * The origin a remote agent should be pointed at.
11
+ *
12
+ * `0.0.0.0` is a statement about listening, not an address: an agent handed `http://0.0.0.0:8080`
13
+ * fails in a way that looks like orca is broken rather than like the invocation was incomplete. So
14
+ * a wildcard bind must be paired with the name the sandbox actually reaches this machine by, and
15
+ * refusing is the only honest option — orca cannot discover that name, and guessing one produces a
16
+ * run that records nothing.
17
+ */
18
+ export declare function advertisedUrl(req: AdvertiseRequest): string;
19
+ export interface AttachRequest {
20
+ /** Where the remote agent should send its model traffic. */
21
+ proxyUrl: string;
22
+ /** Variables the named adapter would have set, already pointed at `proxyUrl`. */
23
+ adapterEnv: Record<string, string>;
24
+ /** Present only when the run is intercepting. Paths are local, and are only for the copy step. */
25
+ ca?: {
26
+ certPath: string;
27
+ bundlePath: string;
28
+ };
29
+ /** Where the CA will live *in the sandbox* once copied. Every printed path uses this. */
30
+ remoteCaPath?: string;
31
+ }
32
+ /**
33
+ * The variables to set on the far side.
34
+ *
35
+ * The CA paths are the remote ones throughout. Printing this machine's path is the subtle failure
36
+ * this function exists to prevent: the block runs cleanly, every variable is set, and the agent
37
+ * trusts nothing — because the file those variables name is not there.
38
+ */
39
+ export declare function attachExports(req: AttachRequest): Record<string, string>;
40
+ /**
41
+ * The block the operator pastes into the sandbox.
42
+ *
43
+ * The copy step comes first because the export block references a file that has to exist by the
44
+ * time the agent starts, and an operator who pastes the second half alone gets an agent that
45
+ * cannot verify the proxy it was just told to trust.
46
+ */
47
+ export declare function attachInstructions(req: AttachRequest): string[];
48
+ //# sourceMappingURL=attach.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.d.ts","sourceRoot":"","sources":["../src/attach.ts"],"names":[],"mappings":"AAsCA,MAAM,WAAW,gBAAgB;IAC/B,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,IAAI,EAAE,MAAM,CAAC;IACb,uFAAuF;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAkB3D;AA2BD,MAAM,WAAW,aAAa;IAC5B,4DAA4D;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kGAAkG;IAClG,EAAE,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9C,yFAAyF;IACzF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA4BxE;AAOD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,EAAE,CAgB/D"}
package/dist/attach.js ADDED
@@ -0,0 +1,153 @@
1
+ import { isIP } from 'node:net';
2
+ /**
3
+ * Recording an agent orca does not launch.
4
+ *
5
+ * Every other capture path here ends in a child process: an adapter builds an environment and orca
6
+ * spawns the agent inside it. That is the design, and it has exactly one boundary it cannot cross
7
+ * — an agent that is not on this machine. A bot on a VPS, an agent inside a dev container or a
8
+ * cloud sandbox, a harness someone else's CI runs: there is no process to spawn, so there is no
9
+ * environment to build.
10
+ *
11
+ * Orca can still be reachable, and can still say precisely what to set. The proxy already accepts
12
+ * a bind address. What was missing is this half: the block of variables the operator pastes on the
13
+ * far side, which has to be right or the run comes back empty with nothing to explain why.
14
+ *
15
+ * Everything here is pure and returns data rather than printing, because the failure mode being
16
+ * guarded against is a *wrong instruction* — a path that exists here and not there, a wildcard
17
+ * address nothing can dial — and those are only testable if the strings are values.
18
+ */
19
+ /**
20
+ * Variable names whose values must never be printed.
21
+ *
22
+ * The block goes to a terminal, and a terminal is scrollback, a screen share, a pasted ticket and
23
+ * a CI log. Over-matching here costs a line the operator did not need; under-matching prints
24
+ * somebody's API key, so the pattern is deliberately broad.
25
+ *
26
+ * Omitting them is also simply correct. The agent in the sandbox already holds whatever credential
27
+ * it authenticates with, and orca forwards it upstream untouched. Exporting the local machine's
28
+ * key would leak it; exporting the `orca-recorded` placeholder an adapter substitutes when the
29
+ * local environment has none would be worse — it would overwrite a working key over there and turn
30
+ * a functioning setup into a 401.
31
+ */
32
+ const CREDENTIAL_LIKE = /(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)$/;
33
+ /** Bind addresses that mean "every interface", and so mean nothing as a destination. */
34
+ const WILDCARD_BINDS = new Set(['0.0.0.0', '::', '[::]', '*']);
35
+ /**
36
+ * The origin a remote agent should be pointed at.
37
+ *
38
+ * `0.0.0.0` is a statement about listening, not an address: an agent handed `http://0.0.0.0:8080`
39
+ * fails in a way that looks like orca is broken rather than like the invocation was incomplete. So
40
+ * a wildcard bind must be paired with the name the sandbox actually reaches this machine by, and
41
+ * refusing is the only honest option — orca cannot discover that name, and guessing one produces a
42
+ * run that records nothing.
43
+ */
44
+ export function advertisedUrl(req) {
45
+ if (req.advertise !== undefined && req.advertise.trim() !== '') {
46
+ const host = req.advertise
47
+ .trim()
48
+ .replace(/^https?:\/\//, '')
49
+ .replace(/\/+$/, '');
50
+ // A host the operator wrote a port into is used as-is: behind a port-mapped container the
51
+ // reachable port is not the one orca bound, and orca has no way to learn the mapping.
52
+ return `http://${hasPort(host) ? host : `${bracketed(host)}:${req.port}`}`;
53
+ }
54
+ if (WILDCARD_BINDS.has(req.bind)) {
55
+ throw new Error(`--bind ${req.bind} listens on every interface, which is not an address an agent can ` +
56
+ 'connect to. Add --advertise <host> naming how the sandbox reaches this machine, ' +
57
+ 'e.g. --advertise 10.0.0.5 or --advertise host.docker.internal.');
58
+ }
59
+ return `http://${bracketed(req.bind)}:${req.port}`;
60
+ }
61
+ /** A bare IPv6 literal is not a URL host until it is bracketed. */
62
+ function bracketed(host) {
63
+ if (host.startsWith('['))
64
+ return host;
65
+ return isIP(host) === 6 ? `[${host}]` : host;
66
+ }
67
+ /**
68
+ * Does this host already carry its own port?
69
+ *
70
+ * An IPv6 literal is mostly colons, so the last one says nothing on its own: `fd00::1` ends in
71
+ * `:1`, which reads as a port and is not one. Answering wrongly drops the real port and hands the
72
+ * sandbox `http://fd00::1`, which means port 80 and connects to nothing.
73
+ *
74
+ * A bracketed host is unambiguous — anything after the `]` is a port. An unbracketed one carries a
75
+ * port only if it has exactly one colon, because more than one makes it an address rather than a
76
+ * host-and-port.
77
+ */
78
+ function hasPort(host) {
79
+ const close = host.lastIndexOf(']');
80
+ if (close !== -1)
81
+ return /^:\d+$/.test(host.slice(close + 1));
82
+ const first = host.indexOf(':');
83
+ if (first === -1 || first !== host.lastIndexOf(':'))
84
+ return false;
85
+ return /^\d+$/.test(host.slice(first + 1));
86
+ }
87
+ /**
88
+ * The variables to set on the far side.
89
+ *
90
+ * The CA paths are the remote ones throughout. Printing this machine's path is the subtle failure
91
+ * this function exists to prevent: the block runs cleanly, every variable is set, and the agent
92
+ * trusts nothing — because the file those variables name is not there.
93
+ */
94
+ export function attachExports(req) {
95
+ const env = {};
96
+ for (const [name, value] of Object.entries(req.adapterEnv)) {
97
+ if (CREDENTIAL_LIKE.test(name))
98
+ continue;
99
+ env[name] = value;
100
+ }
101
+ if (!req.ca || !req.remoteCaPath)
102
+ return env;
103
+ env.HTTPS_PROXY = req.proxyUrl;
104
+ env.https_proxy = req.proxyUrl;
105
+ // One path for all of them, unlike a local run. The bundle exists so an intercepted child keeps
106
+ // reaching the hosts orca deliberately does not decrypt; a sandbox that already trusts the
107
+ // public roots needs only the run CA added, and shipping one file is what makes the copy step a
108
+ // single line the operator will actually run.
109
+ for (const name of [
110
+ 'NODE_EXTRA_CA_CERTS',
111
+ 'SSL_CERT_FILE',
112
+ 'REQUESTS_CA_BUNDLE',
113
+ 'CURL_CA_BUNDLE',
114
+ 'AWS_CA_BUNDLE',
115
+ 'DENO_CERT',
116
+ ]) {
117
+ env[name] = req.remoteCaPath;
118
+ }
119
+ // Deliberately not carried across. `NO_PROXY` is set for a local run so the agent's plaintext
120
+ // calls to the proxy do not loop back through it; inherited into a sandbox it is just a list of
121
+ // hosts that machine will refuse to proxy, which is a silent way to record nothing.
122
+ return env;
123
+ }
124
+ /** Single-quote a value so no shell can reinterpret it, however it was spelled. */
125
+ function shellQuote(value) {
126
+ return `'${value.replace(/'/g, `'\\''`)}'`;
127
+ }
128
+ /**
129
+ * The block the operator pastes into the sandbox.
130
+ *
131
+ * The copy step comes first because the export block references a file that has to exist by the
132
+ * time the agent starts, and an operator who pastes the second half alone gets an agent that
133
+ * cannot verify the proxy it was just told to trust.
134
+ */
135
+ export function attachInstructions(req) {
136
+ const lines = [];
137
+ if (req.ca && req.remoteCaPath) {
138
+ lines.push(`# 1. copy the run's certificate authority into the sandbox:`);
139
+ lines.push(`# scp ${req.ca.bundlePath} <sandbox>:${req.remoteCaPath}`);
140
+ lines.push(`# (or: docker cp ${req.ca.bundlePath} <container>:${req.remoteCaPath})`);
141
+ lines.push(`# 2. then, in the sandbox, before starting your agent:`);
142
+ }
143
+ else {
144
+ lines.push(`# in the sandbox, before starting your agent:`);
145
+ }
146
+ for (const [name, value] of Object.entries(attachExports(req))) {
147
+ lines.push(`export ${name}=${shellQuote(value)}`);
148
+ }
149
+ // Said out loud, because a block with no key in it reads as an incomplete one.
150
+ lines.push(`# your agent's own credential is unchanged — orca forwards it upstream`);
151
+ return lines;
152
+ }
153
+ //# sourceMappingURL=attach.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.js","sourceRoot":"","sources":["../src/attach.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ import type { ParsedArgs } from '../args.js';
2
+ import type { Output } from '../out.js';
3
+ /**
4
+ * `orca attach` — record an agent this machine did not start.
5
+ *
6
+ * Everything else here launches the agent: an adapter builds an environment, orca spawns the
7
+ * process, and capture follows from that. It is a good design with one boundary it cannot cross —
8
+ * an agent that is not on this machine. A bot on a VPS, an agent inside a dev container or a cloud
9
+ * sandbox, a harness in someone else's CI: there is no process to spawn, so there is no
10
+ * environment to build and no way to say `orca record` at all.
11
+ *
12
+ * So this inverts it. Orca holds the proxy open, prints exactly what to export on the far side,
13
+ * and records whatever arrives until it is stopped. The recording that comes out is not a lesser
14
+ * one: same matcher, same events, same `orca replay`.
15
+ *
16
+ * The adapters are still what produce the variables — `--for claude` prints what the Claude Code
17
+ * adapter would have set, pointed at the advertised address instead of a loopback one. That keeps
18
+ * one source of truth for "which variable does this harness read", so a sandbox recording cannot
19
+ * drift from a local one.
20
+ */
21
+ export interface AttachResult {
22
+ runId: string;
23
+ runDir: string;
24
+ modelExchanges: number;
25
+ events: number;
26
+ proxyUrl: string;
27
+ /** Replay sessions only: recorded exchanges served back, and requests nothing matched. */
28
+ reused: number;
29
+ unmatched: number;
30
+ /**
31
+ * Non-zero only when a replay session could not answer something the agent asked for.
32
+ *
33
+ * A recording session cannot fail this way: it ends because the operator stopped it, which is
34
+ * how it is meant to end. Whether it captured anything is reported by `capture.empty`, because
35
+ * an empty capture is a mistake to point at rather than an error to exit on.
36
+ */
37
+ exitCode: number;
38
+ }
39
+ export interface AttachOptions {
40
+ /**
41
+ * Resolves when the session should stop. Defaults to SIGINT — the operator pressing ctrl-C,
42
+ * which is how a session with no child process to wait on ends.
43
+ */
44
+ until?: Promise<void>;
45
+ /** Called once the proxy is listening, with the address a remote agent should use. */
46
+ onReady?: (info: {
47
+ proxyUrl: string;
48
+ runId: string;
49
+ }) => void;
50
+ }
51
+ export declare function attachCommand(args: ParsedArgs, out: Output, cwd?: string, options?: AttachOptions): Promise<AttachResult>;
52
+ //# sourceMappingURL=attach.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.d.ts","sourceRoot":"","sources":["../../src/commands/attach.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AASxC;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,0FAA0F;IAC1F,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;OAMG;IACH,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,sFAAsF;IACtF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;CAC/D;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,MAAM,EACX,GAAG,SAAgB,EACnB,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CAQvB"}
@@ -0,0 +1,285 @@
1
+ import { once } from 'node:events';
2
+ import { TraceReader, TraceWriter, ensureRunsDir, resolveRunSelector } from '@orcareplay/core';
3
+ import { createProxy } from '@orcareplay/proxy';
4
+ import { defaultAdapters } from '@orcareplay/adapters';
5
+ import { advertisedUrl, attachExports, attachInstructions } from '../attach.js';
6
+ import { ExchangeEventDeriver, appendDerivedEvents } from '../exchange-events.js';
7
+ import { SerialQueue } from '../serial.js';
8
+ import { planTlsCapture, recordedTlsHosts, setupTlsCapture } from '../tls-capture.js';
9
+ import { loadExchanges } from './replay.js';
10
+ import { upstreamPlan } from '../upstream.js';
11
+ import { ORCA_VERSION } from '../version.js';
12
+ export async function attachCommand(args, out, cwd = process.cwd(), options = {}) {
13
+ const minted = [];
14
+ try {
15
+ return await runAttached(args, out, cwd, options, minted);
16
+ }
17
+ catch (err) {
18
+ for (const ca of minted)
19
+ await ca.dispose().catch(() => undefined);
20
+ throw err;
21
+ }
22
+ }
23
+ async function runAttached(args, out, cwd, options, minted) {
24
+ const registry = defaultAdapters();
25
+ const bind = args.str('bind') ?? '127.0.0.1';
26
+ const advertise = args.str('advertise');
27
+ const requestedPort = args.num('port') ?? 0;
28
+ // Resolved before the run directory is made, so an invocation that cannot work — a wildcard bind
29
+ // with nothing to advertise — leaves nothing behind to clean up. The port is not known yet when
30
+ // orca is choosing one, and does not need to be: only the host can make this fail.
31
+ advertisedUrl({
32
+ bind,
33
+ port: requestedPort === 0 ? 1 : requestedPort,
34
+ ...(advertise === undefined ? {} : { advertise }),
35
+ });
36
+ /**
37
+ * Serving a recording back instead of recording a new one.
38
+ *
39
+ * `orca replay` launches the agent, which is right for a run orca recorded by launching it and
40
+ * impossible for one that came from a sandbox — the agent may not exist on this machine at all.
41
+ * So replay attaches too: same proxy, same matcher, same blocked egress, and the operator points
42
+ * the same remote agent at it.
43
+ */
44
+ // The parser turns a flag with no value into `true`, so a bare `--replay` — or `--replay
45
+ // --tls-intercept`, where the next token is itself a flag — would read as absent and start a
46
+ // *recording* session against the live provider. That is the worst direction to fail in.
47
+ if (args.has('replay') && args.str('replay') === undefined) {
48
+ throw new Error('--replay needs the run to serve: orca attach --replay <run>, or --replay last');
49
+ }
50
+ const replaySelector = args.str('replay');
51
+ const replaying = replaySelector
52
+ ? await (async () => {
53
+ const runDir = (await resolveRunSelector(cwd, replaySelector)).dir;
54
+ const reader = await TraceReader.open(runDir);
55
+ return {
56
+ exchanges: await loadExchanges(reader),
57
+ hosts: recordedTlsHosts(await reader.events()),
58
+ // Which harness made the recording. It will be the one connecting again, so it is the
59
+ // right default for the block the operator pastes.
60
+ adapter: reader.manifest().adapter?.id,
61
+ };
62
+ })()
63
+ : undefined;
64
+ /**
65
+ * `exec` is the honest default for a fresh session: orca does not know what will connect, and
66
+ * detecting an agent from this directory would be a guess about a machine it cannot see. For a
67
+ * replay it is the wrong default and a useless one — `exec` sets no variables, so the block the
68
+ * operator is meant to paste comes out empty. The recording names the harness, so use it.
69
+ */
70
+ const adapterName = args.str('for') ?? replaying?.adapter ?? 'exec';
71
+ const adapter = registry.get(adapterName);
72
+ // The last thing that can refuse the run, and it runs before the trace exists: a writer created
73
+ // first and abandoned by a throw leaves an unsealed run directory behind, which then wins `last`
74
+ // — so the next command someone types operates on the session that never happened.
75
+ planTlsCapture(args, replaying?.hosts);
76
+ const dir = await ensureRunsDir(cwd);
77
+ const writer = await TraceWriter.create(dir, {
78
+ adapter: { id: adapter.id, version: ORCA_VERSION, harness_version: adapter.harnessVersions },
79
+ argv: [adapterName],
80
+ cwd,
81
+ orcaVersion: ORCA_VERSION,
82
+ });
83
+ let modelExchanges = 0;
84
+ let turn = 0;
85
+ let unmatched = 0;
86
+ const writes = new SerialQueue();
87
+ const plan = await upstreamPlan(args);
88
+ const tls = await setupTlsCapture({
89
+ args,
90
+ out,
91
+ ...(replaying ? { recordedHosts: replaying.hosts } : {}),
92
+ writer,
93
+ writes,
94
+ // Out-of-band traffic belongs to whichever turn was in progress when it happened, exactly as in
95
+ // a launched recording. A fixed 0 filed every net.* event against the start of the session.
96
+ turn: () => turn,
97
+ });
98
+ if (tls.ca)
99
+ minted.push(tls.ca);
100
+ // Read before the proxy closes: `stats()` is a snapshot of a live object, and the teardown in
101
+ // `finally` is the last moment it is still meaningful.
102
+ let stats = {
103
+ matchedExact: 0,
104
+ matchedInexact: 0,
105
+ unmatched: 0,
106
+ };
107
+ const deriver = new ExchangeEventDeriver();
108
+ const proxy = await createProxy({
109
+ // A replay session serves the recording and blocks egress, exactly as `orca replay` does. The
110
+ // only difference is which side starts the agent, and that is not a difference the proxy has.
111
+ mode: replaying ? 'replay' : 'record',
112
+ ...(replaying ? { exchanges: replaying.exchanges, loose: args.bool('loose') } : {}),
113
+ host: bind,
114
+ port: requestedPort,
115
+ upstream: plan.upstream,
116
+ upstreamHeaders: plan.headers,
117
+ ...tls.proxyOptions,
118
+ onUnmatched: () => {
119
+ unmatched += 1;
120
+ },
121
+ onExchange: (exchange) => {
122
+ modelExchanges += 1;
123
+ turn += 1;
124
+ const at = turn;
125
+ writes.push(() => appendDerivedEvents(writer, deriver, exchange, at));
126
+ },
127
+ });
128
+ const advertised = advertisedUrl({
129
+ bind,
130
+ port: proxy.port,
131
+ ...(advertise === undefined ? {} : { advertise }),
132
+ });
133
+ try {
134
+ const proxyUrl = advertised;
135
+ // What the adapter would have put into a child's environment, pointed at the reachable
136
+ // address rather than a loopback one. `runDir` is this machine's, and is only ever used by
137
+ // adapters that write a scratch file — none of which a remote agent would read, which is why
138
+ // `--for` is documented as the variables rather than as full instrumentation.
139
+ const launch = await adapter.prepare({
140
+ runId: writer.runId,
141
+ cwd,
142
+ runDir: writer.runDir,
143
+ proxyUrl,
144
+ userArgs: ['<your agent>'],
145
+ env: process.env,
146
+ });
147
+ const remoteCaPath = args.str('remote-ca-path') ?? '/tmp/orca-ca.crt';
148
+ const instructions = attachInstructions({
149
+ proxyUrl,
150
+ adapterEnv: launch.env,
151
+ ...(proxy.tls
152
+ ? {
153
+ ca: { certPath: proxy.tls.caCertPath, bundlePath: proxy.tls.caBundlePath },
154
+ remoteCaPath,
155
+ }
156
+ : {}),
157
+ });
158
+ out.phase('attached', {
159
+ run: writer.runId,
160
+ proxy: proxyUrl,
161
+ for: adapter.id,
162
+ ...(replaying
163
+ ? { serving: replaySelector, exchanges: replaying.exchanges.length, egress: 'blocked' }
164
+ : {}),
165
+ });
166
+ if (proxy.tls) {
167
+ out.warn('tls.intercepting', {
168
+ hosts: proxy.tls.hosts,
169
+ ca_sha256: proxy.tls.fingerprint,
170
+ });
171
+ await writer.append({
172
+ type: 'note',
173
+ actor: 'orca',
174
+ turn: 0,
175
+ attrs: {
176
+ rule: 'tls_intercept',
177
+ hosts: proxy.tls.hosts,
178
+ ca_sha256: proxy.tls.fingerprint,
179
+ },
180
+ });
181
+ }
182
+ /**
183
+ * An instruction the operator cannot act on is worse than none: the block pastes cleanly, the
184
+ * session waits, and nothing ever connects. Both shapes of that are visible from here.
185
+ */
186
+ const exported = attachExports({
187
+ proxyUrl,
188
+ adapterEnv: launch.env,
189
+ ...(proxy.tls
190
+ ? {
191
+ ca: { certPath: proxy.tls.caCertPath, bundlePath: proxy.tls.caBundlePath },
192
+ remoteCaPath,
193
+ }
194
+ : {}),
195
+ });
196
+ if (Object.keys(exported).length === 0) {
197
+ out.warn('attach.nothing_to_set', {
198
+ for: adapter.id,
199
+ cause: `${adapter.id} redirects nothing, and this session is not intercepting`,
200
+ next: 'name the harness with --for <agent>, or add --tls-intercept',
201
+ });
202
+ }
203
+ // A path under the run directory exists on this machine and nowhere else. The fetch hook is
204
+ // the live example: `--for node` names a preload orca just wrote here, so the block would
205
+ // instrument nothing over there while looking exactly as though it had.
206
+ const local = Object.entries(exported)
207
+ .filter(([, value]) => value.includes(writer.runDir))
208
+ .map(([name]) => name);
209
+ if (local.length > 0) {
210
+ out.warn('attach.local_path', {
211
+ vars: local.join(','),
212
+ cause: `${adapter.id} instruments by writing a file here and naming it in the environment`,
213
+ effect: 'those paths do not exist in the sandbox, so that half of the capture will not run',
214
+ next: 'copy the named files across, or use --tls-intercept instead',
215
+ });
216
+ }
217
+ out.plain('');
218
+ for (const line of instructions)
219
+ out.plain(` ${line}`);
220
+ out.plain('');
221
+ out.plain(replaying
222
+ ? ' Serving the recording. Press ctrl-C when the agent is done.'
223
+ : ' Recording. Press ctrl-C when the agent is done.');
224
+ options.onReady?.({ proxyUrl, runId: writer.runId });
225
+ await (options.until ?? firstInterrupt());
226
+ }
227
+ finally {
228
+ stats = proxy.stats();
229
+ await writes.drain();
230
+ // The proxy closes after the writes drain, for the same reason recording does: a tunnel record
231
+ // arriving after the writer shut would have nowhere to go.
232
+ await proxy.close();
233
+ await tls.ca?.dispose();
234
+ }
235
+ /**
236
+ * The same warning a launched run gets, and it matters more here. A remote agent that was never
237
+ * given the block — or was given it in a shell that had already started the agent — produces a
238
+ * session that ends cleanly and records nothing, and there is no exit code to hint at it.
239
+ */
240
+ if (modelExchanges === 0 && !replaying) {
241
+ out.warn('capture.empty', {
242
+ exchanges: 0,
243
+ cause: 'nothing connected to the proxy while the session was open',
244
+ next: 'check the sandbox can reach the advertised address, and that the exports were set',
245
+ });
246
+ }
247
+ const manifest = await writer.close(0);
248
+ // A replay session did not record a run, and saying it did would send someone looking for turns
249
+ // in a trace that holds none. It reports what it actually did: how much of the recording the
250
+ // agent asked for, and what it asked for that the recording could not answer.
251
+ if (replaying) {
252
+ out.info('served', {
253
+ serving: replaySelector,
254
+ reused: `${stats.matchedExact + stats.matchedInexact}/${replaying.exchanges.length}`,
255
+ unmatched: stats.unmatched,
256
+ });
257
+ }
258
+ else {
259
+ out.info('recorded', {
260
+ run: writer.runId,
261
+ events: manifest.counts?.events ?? 0,
262
+ exchanges: modelExchanges,
263
+ dir: writer.runDir,
264
+ });
265
+ }
266
+ return {
267
+ runId: writer.runId,
268
+ runDir: writer.runDir,
269
+ modelExchanges,
270
+ events: manifest.counts?.events ?? 0,
271
+ // The address the agent should use, not the one orca bound — a caller reading this from
272
+ // `--json` needs something it can connect to, which a wildcard bind is not.
273
+ proxyUrl: advertised,
274
+ // Exact plus inexact: both were served from the recording, which is what "reused" means here.
275
+ // The distinction between them is a divergence, and divergences are reported on their own.
276
+ reused: replaying ? stats.matchedExact + stats.matchedInexact : 0,
277
+ unmatched: replaying ? stats.unmatched : unmatched,
278
+ exitCode: replaying && stats.unmatched > 0 ? 1 : 0,
279
+ };
280
+ }
281
+ /** Ctrl-C, which is the only way an operator ends a session with no child process to wait on. */
282
+ async function firstInterrupt() {
283
+ await once(process, 'SIGINT');
284
+ }
285
+ //# sourceMappingURL=attach.js.map