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 +104 -0
- package/dist/attach.d.ts +48 -0
- package/dist/attach.d.ts.map +1 -0
- package/dist/attach.js +153 -0
- package/dist/attach.js.map +1 -0
- package/dist/commands/attach.d.ts +52 -0
- package/dist/commands/attach.d.ts.map +1 -0
- package/dist/commands/attach.js +285 -0
- package/dist/commands/attach.js.map +1 -0
- package/dist/commands/replay.js +16 -2
- package/dist/commands/replay.js.map +1 -1
- package/dist/flags.d.ts +14 -0
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +20 -3
- package/dist/flags.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +29 -2
- package/dist/main.js.map +1 -1
- package/dist/tls-capture.d.ts +23 -0
- package/dist/tls-capture.d.ts.map +1 -1
- package/dist/tls-capture.js +118 -13
- package/dist/tls-capture.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -12
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
|
+

|
|
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.
|
package/dist/attach.d.ts
ADDED
|
@@ -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
|