@tanstack/ai-sandbox 0.2.4 → 0.3.0
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/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent output journal: an append-only NDJSON file INSIDE the sandbox that
|
|
3
|
+
* the agent's stdout is redirected to, and that the host tails.
|
|
4
|
+
*
|
|
5
|
+
* This module is pure string composition — no I/O — so every shell fragment the
|
|
6
|
+
* feature depends on is unit-testable without a sandbox, and a successor host
|
|
7
|
+
* derives byte-identical commands from the `runId` alone.
|
|
8
|
+
*
|
|
9
|
+
* Three rules are encoded here and must not be relaxed:
|
|
10
|
+
*
|
|
11
|
+
* 1. **No pipe from the agent.** The agent's stdout is *redirected*, never
|
|
12
|
+
* piped. `agent | tee file` gives the agent a reader whose disappearance
|
|
13
|
+
* SIGPIPEs it — precisely the host-death failure this feature exists to
|
|
14
|
+
* prevent. Redirection leaves nothing to break.
|
|
15
|
+
* 2. **Every read silences stderr; only the BOUNDED read base64-frames its
|
|
16
|
+
* output.** `2>/dev/null` is on both: Daytona's `exec` folds stderr into
|
|
17
|
+
* stdout (`stderr: ''`, by contract) and Sprites' fast path does too, so a
|
|
18
|
+
* `tail` diagnostic would otherwise splice itself into the event bytes.
|
|
19
|
+
* Silencing it inside the sandbox means there is nothing left to fold.
|
|
20
|
+
*
|
|
21
|
+
* base64, however, is only on {@link journalReadCommand}. It cannot be on
|
|
22
|
+
* {@link journalFollowCommand}: `base64` fully buffers its stdout when that
|
|
23
|
+
* is a pipe rather than a tty, so `tail -f file | base64` emits NOTHING
|
|
24
|
+
* until the ~4KB libc stdio buffer fills or `base64`'s stdin closes — and
|
|
25
|
+
* `tail -f`'s stdin never closes until the reader kills it, by which point
|
|
26
|
+
* the consumer has stopped reading. Measured on GNU coreutils 8.32 `base64`
|
|
27
|
+
* (0 bytes delivered over 12s) and on busybox 1.36.1 `base64` in Alpine
|
|
28
|
+
* (identical), so it is a property of stdio, not of a provider or an OS.
|
|
29
|
+
* `stdbuf -o0` does not fix it portably (absent from busybox entirely) and
|
|
30
|
+
* re-`exec`ing `base64` per line costs a fork per journal event.
|
|
31
|
+
*
|
|
32
|
+
* Dropping it from the follow path is safe because the bounded read keeps
|
|
33
|
+
* every property base64 was chosen for where that path needs them, and the
|
|
34
|
+
* follow path needs none of them: `2>/dev/null` already prevents the
|
|
35
|
+
* stderr splice, the journal is line-delimited JSON (a raw newline can only
|
|
36
|
+
* ever be a record separator — inside a JSON string it is `\n`), and
|
|
37
|
+
* `journal-bytes.ts` reassembles bytes across chunk boundaries and yields
|
|
38
|
+
* only newline-terminated lines. The follow path therefore consumes
|
|
39
|
+
* `SpawnHandle.stdout` exactly as `runner.ts` already consumes the agent's
|
|
40
|
+
* own stdout, i.e. it relies on the same provider decoding contract the
|
|
41
|
+
* package already depends on rather than a stricter one.
|
|
42
|
+
* 3. **The journal is touched ONLY through the shell.** On local-process,
|
|
43
|
+
* `fs.write` resolves `/tmp` under the sandbox root while a shell redirect
|
|
44
|
+
* hits the real host `/tmp`. Both halves agree with each other only as long
|
|
45
|
+
* as nothing uses `fs.*` here — hence {@link journalExistsCommand} rather
|
|
46
|
+
* than `handle.fs.exists`.
|
|
47
|
+
*
|
|
48
|
+
* The composed commands below are handed to two different execution
|
|
49
|
+
* mechanisms depending on provider, not always `sh -c`: daytona hands the raw
|
|
50
|
+
* string to `executeCommand` with an `export`-prefixed env, and cloudflare
|
|
51
|
+
* hands it to a Durable Object RPC. Redirection, `mkdir -p`, `tail`, and
|
|
52
|
+
* `base64` all still work because both paths are shell-interpreted
|
|
53
|
+
* downstream — the doc comment intentionally does not claim every provider
|
|
54
|
+
* wraps the command in `sh -c` itself.
|
|
55
|
+
*/
|
|
56
|
+
/** Default journal directory. `/tmp` is the convention the harness adapters already use. */
|
|
57
|
+
export declare const DEFAULT_JOURNAL_DIR = "/tmp/tanstack-runs";
|
|
58
|
+
/**
|
|
59
|
+
* Key of the sentinel object the journaled command appends after the agent
|
|
60
|
+
* exits. It tells a *new* host the agent finished, with no pid probe and no
|
|
61
|
+
* provider-specific liveness API — which matters because `pid` is `-1` on five
|
|
62
|
+
* of six providers.
|
|
63
|
+
*/
|
|
64
|
+
export declare const EXIT_SENTINEL_KEY = "__exit";
|
|
65
|
+
/**
|
|
66
|
+
* Key carrying the per-run sentinel nonce that makes the sentinel
|
|
67
|
+
* DISTINGUISHABLE from agent output.
|
|
68
|
+
*
|
|
69
|
+
* **Why the nonce exists.** `journaledCommand` redirects the agent's stdout and
|
|
70
|
+
* the sentinel `printf` into the SAME file with no framing, so on the wire an
|
|
71
|
+
* agent's own line is indistinguishable from the shell's. Without a nonce, any
|
|
72
|
+
* agent that ever prints a JSON object carrying `__exit` — echoing a fixture,
|
|
73
|
+
* `cat`-ing a file, dumping diagnostics — makes {@link parseJournalExit} report a
|
|
74
|
+
* MID-FLIGHT run as finished, and `reapOne` then drives that run to terminal and
|
|
75
|
+
* reclaims its sandbox out from under a live agent. A confident wrong answer is
|
|
76
|
+
* strictly worse than the `'unknown'` every other failure on that path returns.
|
|
77
|
+
*
|
|
78
|
+
* **What the nonce is.** A domain-separated SHA-256 of the runId (see
|
|
79
|
+
* {@link journalPaths}), NOT process-random. It has to be recomputable by a
|
|
80
|
+
* SUCCESSOR host from the run record alone — that is this module's stated
|
|
81
|
+
* contract ("a successor host derives byte-identical commands from the `runId`
|
|
82
|
+
* alone"), and the reaper's probe runs in a different process from the one that
|
|
83
|
+
* composed the command, with nothing but the runId to go on. A process-random
|
|
84
|
+
* nonce would make every journal written by a dead host unreadable.
|
|
85
|
+
*
|
|
86
|
+
* **The residual, stated honestly.** Because it is derived rather than secret,
|
|
87
|
+
* an agent that knows its own runId AND reimplements this derivation could still
|
|
88
|
+
* emit a matching line. What the nonce removes is the entire accidental class —
|
|
89
|
+
* which is the class that actually occurs — and it removes it completely. Closing
|
|
90
|
+
* the deliberate case needs a secret the successor host can also read, i.e. a
|
|
91
|
+
* nonce persisted on the run record; that is a `RunStore` schema change, not a
|
|
92
|
+
* change to this pure-composition module. Two further mitigations narrow the
|
|
93
|
+
* deliberate case: {@link parseJournalExit} takes the LAST matching sentinel in
|
|
94
|
+
* the window rather than the first (the shell always writes the real one after
|
|
95
|
+
* the agent's own output), and a matching sentinel whose code is not an integer
|
|
96
|
+
* is refused rather than coerced to 0.
|
|
97
|
+
*/
|
|
98
|
+
export declare const EXIT_SENTINEL_NONCE_KEY = "__nonce";
|
|
99
|
+
/** Absolute in-sandbox paths for one run's journal. */
|
|
100
|
+
export interface JournalPaths {
|
|
101
|
+
/** Directory both files live in; created by {@link journaledCommand}. */
|
|
102
|
+
dir: string;
|
|
103
|
+
/** Append-only NDJSON file the agent's stdout is redirected to. */
|
|
104
|
+
journal: string;
|
|
105
|
+
/** Separate file the agent's stderr goes to; NEVER mixed into the journal. */
|
|
106
|
+
stderr: string;
|
|
107
|
+
/**
|
|
108
|
+
* Per-run nonce the exit sentinel carries, so agent stdout cannot forge it.
|
|
109
|
+
* See {@link EXIT_SENTINEL_NONCE_KEY}. Carried alongside the paths because
|
|
110
|
+
* every producer and every reader of the sentinel already threads a
|
|
111
|
+
* `JournalPaths` through, and the two must agree or the run reads as
|
|
112
|
+
* unterminated.
|
|
113
|
+
*/
|
|
114
|
+
nonce: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The exact sentinel LINE (no trailing newline) `journaledCommand` appends for
|
|
118
|
+
* `exitCode`.
|
|
119
|
+
*
|
|
120
|
+
* Exported because a test or a fake host that seeds a journal by hand has to
|
|
121
|
+
* write the same bytes the shell would; hand-writing `{"__exit":0}` produces a
|
|
122
|
+
* line the reader now correctly refuses. Key order matches the `printf` format
|
|
123
|
+
* below, and both are asserted against each other in `journal.test.ts`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function exitSentinelLine(paths: JournalPaths, exitCode: number): string;
|
|
126
|
+
/**
|
|
127
|
+
* Map a runId to a filename-safe token that is INJECTIVE: distinct runIds
|
|
128
|
+
* must never produce the same token, because the journal is looked up by
|
|
129
|
+
* this token alone and a collision means two runs would share one journal —
|
|
130
|
+
* one run's takeover replaying another run's transcript.
|
|
131
|
+
*
|
|
132
|
+
* Encoding rather than rejecting keeps the mapping total: a client may choose
|
|
133
|
+
* any `runId`, and a run that cannot be journaled would be a run that cannot be
|
|
134
|
+
* made durable. The encoding is a pure function of the input, which is what lets
|
|
135
|
+
* a successor host recompute the same path from the run record alone.
|
|
136
|
+
*
|
|
137
|
+
* The scheme is a straightforward escaping over `_`: any character matching
|
|
138
|
+
* `[A-Za-z0-9.-]` passes through literally; everything else — INCLUDING a
|
|
139
|
+
* literal `_` — is replaced by `_` followed by two lowercase hex digits per
|
|
140
|
+
* UTF-8 byte. Because `_` itself is never a safe (pass-through) character,
|
|
141
|
+
* every `_` in the output unambiguously starts a two-hex-digit escape; a
|
|
142
|
+
* left-to-right scan can always tell literal from escape. That is what makes
|
|
143
|
+
* the mapping injective: two different inputs can never parse to the same
|
|
144
|
+
* output, because the (unimplemented, but well-defined) decoder is
|
|
145
|
+
* deterministic — if it were not injective, running that decoder on a shared
|
|
146
|
+
* output would have to yield both original strings, which is impossible for a
|
|
147
|
+
* deterministic function.
|
|
148
|
+
*
|
|
149
|
+
* This is a DELIBERATE change from a prior scheme that also treated `_` as
|
|
150
|
+
* safe. That made the encoding non-injective: `_` doubled as both a literal
|
|
151
|
+
* and the escape prefix, so an escaped byte could read back as a literal
|
|
152
|
+
* escape sequence typed by someone else. Concretely, under the old scheme
|
|
153
|
+
* `encodeRunId('@')` and `encodeRunId('_40')` both produced `'_40'` — `@` is
|
|
154
|
+
* `0x40` and gets escaped to `_40`, while the literal characters `_`, `4`, `0`
|
|
155
|
+
* were all "safe" and passed through unchanged. This change breaks that
|
|
156
|
+
* collision by escaping `_` like any other unsafe character.
|
|
157
|
+
*
|
|
158
|
+
* BREAKING CHANGE for existing journals: a journal file written under the
|
|
159
|
+
* old scheme (where a literal `_` in the runId was left unescaped) will not
|
|
160
|
+
* be found by this scheme, because a runId containing `_` now encodes
|
|
161
|
+
* differently. Durability has not shipped publicly yet (this repo has no
|
|
162
|
+
* released version with `encodeRunId` in it), so there is no compatibility
|
|
163
|
+
* obligation and no changeset is warranted — there is nothing in the wild to
|
|
164
|
+
* migrate.
|
|
165
|
+
*
|
|
166
|
+
* EXPORTED for adapters that derive their OWN in-sandbox paths from a `runId`
|
|
167
|
+
* (`ai-codex`'s prompt file and MCP bridge config, `ai-claude-code`'s prompt
|
|
168
|
+
* file). Durability makes `runId` caller-chosen, so an unencoded interpolation
|
|
169
|
+
* lets a `/` produce a directory-bearing path, `..` escape the workdir, and an
|
|
170
|
+
* over-long id fail the spawn with `ENAMETOOLONG` — the same hazards
|
|
171
|
+
* {@link journalPaths} already routes through here. Reuse this rather than
|
|
172
|
+
* writing a second encoder: a divergent copy would reintroduce the
|
|
173
|
+
* non-injectivity documented above.
|
|
174
|
+
*/
|
|
175
|
+
export declare function encodeRunId(runId: string): string;
|
|
176
|
+
/**
|
|
177
|
+
* Reverse of {@link encodeRunId}, for a filename as `ls -1` reports it.
|
|
178
|
+
*
|
|
179
|
+
* `runId` on the success arm is a STORE KEY, never a path component. The
|
|
180
|
+
* encoding is total over client-chosen strings, so a perfectly valid decode can
|
|
181
|
+
* be `'..'`, `'.hidden'`, or `'a/b'` — a caller that interpolates it into a
|
|
182
|
+
* path would escape the journal directory. Look it up in the run store; do not
|
|
183
|
+
* join it onto anything.
|
|
184
|
+
*/
|
|
185
|
+
export type DecodedJournalRunId =
|
|
186
|
+
/** The name decoded to exactly one runId. */
|
|
187
|
+
{
|
|
188
|
+
kind: 'runId';
|
|
189
|
+
runId: string;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The name is length-capped output of {@link encodeRunId}, whose truncating
|
|
193
|
+
* branch is LOSSY. The original runId is unrecoverable — KEEP the file.
|
|
194
|
+
*/
|
|
195
|
+
| {
|
|
196
|
+
kind: 'truncated';
|
|
197
|
+
}
|
|
198
|
+
/** Not output this module could have produced. KEEP the file. */
|
|
199
|
+
| {
|
|
200
|
+
kind: 'malformed';
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Recover the `runId` behind a journal filename — FAIL CLOSED.
|
|
204
|
+
*
|
|
205
|
+
* The consumer of this function DELETES files, so every arm that is not a
|
|
206
|
+
* proven-correct decode must be one the caller keeps. There is no "probably
|
|
207
|
+
* fine" arm.
|
|
208
|
+
*
|
|
209
|
+
* `name` is the filename as {@link journalListCommand} reports it, extension
|
|
210
|
+
* included. The extension is required, not optional: `.` is a pass-through-safe
|
|
211
|
+
* character, so a runId of `'x.ndjson'` encodes to the token `x.ndjson` and the
|
|
212
|
+
* file `x.ndjson.ndjson`. A function that stripped an extension only "if
|
|
213
|
+
* present" could not tell those two strings apart. Requiring it keeps that
|
|
214
|
+
* sharp edge here instead of in every caller that would otherwise reach for
|
|
215
|
+
* `name.split('.')[0]`.
|
|
216
|
+
*
|
|
217
|
+
* **Why `truncated` is a distinct refusal and not a decode.** `encodeRunId`
|
|
218
|
+
* caps its output at {@link MAX_ENCODED_NAME_LENGTH} by replacing the tail with
|
|
219
|
+
* `-` plus a SHA-256 prefix. That branch discards bytes, so the encoding is not
|
|
220
|
+
* invertible there — and because `-` is itself a pass-through-safe character,
|
|
221
|
+
* the truncated form is syntactically indistinguishable from a legitimately
|
|
222
|
+
* encoded id. Decoding it anyway would yield a plausible but WRONG runId; the
|
|
223
|
+
* store would not recognise it, a sweep would read that as "no such run", and
|
|
224
|
+
* it would delete the journal of a run that may still be mid-flight. So any
|
|
225
|
+
* name that *could* be the truncated form is refused, at the cost of never
|
|
226
|
+
* sweeping journals of runIds long enough to hash — a bounded leak, versus
|
|
227
|
+
* data loss on a live run.
|
|
228
|
+
*
|
|
229
|
+
* The truncation check runs BEFORE the character scan on purpose: truncating at
|
|
230
|
+
* a fixed byte offset can cut an `_hh` escape in half, so a truncated name may
|
|
231
|
+
* also be malformed, and the more specific diagnosis is the useful one.
|
|
232
|
+
*
|
|
233
|
+
* The rest is the inverse of the escaping scheme: `[A-Za-z0-9.-]` is a literal
|
|
234
|
+
* ASCII byte, `_` must be followed by EXACTLY two hex digits (either case),
|
|
235
|
+
* and anything else — a bare `_`, a one-digit escape, `/`, `\`, a space — is
|
|
236
|
+
* malformed. The resulting bytes go through a `fatal: true` `TextDecoder`, so
|
|
237
|
+
* an escape sequence that is not valid UTF-8 is a refusal rather than a string
|
|
238
|
+
* silently peppered with U+FFFD (which would be a *different* runId than any
|
|
239
|
+
* encoder input, i.e. exactly the wrong-runId deletion this guards against).
|
|
240
|
+
*/
|
|
241
|
+
export declare function decodeJournalRunId(name: string): DecodedJournalRunId;
|
|
242
|
+
/**
|
|
243
|
+
* Derive both journal paths for a run. Pure; no I/O.
|
|
244
|
+
*
|
|
245
|
+
* **`runId` MUST be unique per run.** The journal is append-only by design (a
|
|
246
|
+
* takeover depends on a prefix a previous host delivered still being there), and
|
|
247
|
+
* {@link DEFAULT_JOURNAL_DIR} is a fixed absolute path that outlives any single
|
|
248
|
+
* sandbox, test, or process. So a reused `runId` does not start a fresh journal
|
|
249
|
+
* — it appends to the old one, behind the old run's `{"__exit":N}` sentinel. A
|
|
250
|
+
* streaming reader stops at the first sentinel it reaches — and a reused runId
|
|
251
|
+
* derives the SAME nonce, so the old run's sentinel matches — meaning the new run
|
|
252
|
+
* appears to emit nothing at all, or to fail with the previous run's exit code.
|
|
253
|
+
* (The nonce is per-run, not per-attempt: it defends against the AGENT forging a
|
|
254
|
+
* sentinel, not against a caller reusing an id.) This is not
|
|
255
|
+
* enforced here on purpose: refusing to append would break the takeover the
|
|
256
|
+
* append-only rule exists for. Callers derive `runId` from something unique
|
|
257
|
+
* (the adapters use a timestamp plus a random suffix); a test that hardcodes a
|
|
258
|
+
* literal `runId` will observe a stale run's journal on its second execution.
|
|
259
|
+
*/
|
|
260
|
+
export declare function journalPaths(runId: string, dir?: string): JournalPaths;
|
|
261
|
+
/**
|
|
262
|
+
* Wrap an agent command so its stdout lands in the journal, its stderr lands in
|
|
263
|
+
* the sidecar file, and an `{"__exit":N,"__nonce":"…"}` sentinel is appended once
|
|
264
|
+
* it exits.
|
|
265
|
+
*
|
|
266
|
+
* The nonce is what keeps the sentinel apart from the agent's own stdout, which
|
|
267
|
+
* lands in the very same file with no framing — see
|
|
268
|
+
* {@link EXIT_SENTINEL_NONCE_KEY}. It is interpolated as a bare hex token inside
|
|
269
|
+
* a single-quoted `printf` FORMAT string, which is safe by construction:
|
|
270
|
+
* {@link deriveExitSentinelNonce} emits `[0-9a-f]` only, so there is no quote to
|
|
271
|
+
* escape and no `%` for `printf` to interpret.
|
|
272
|
+
*
|
|
273
|
+
* `command` is interpolated raw: callers build real shell text (the Claude Code
|
|
274
|
+
* and Codex adapters append `< promptFile`, for instance), so quoting it would
|
|
275
|
+
* break them. Every path this module contributes IS quoted.
|
|
276
|
+
*
|
|
277
|
+
* `>>` rather than `>` on purpose: truncating would let a stray re-spawn destroy
|
|
278
|
+
* a prefix a previous host already translated and delivered.
|
|
279
|
+
*/
|
|
280
|
+
export declare function journaledCommand(command: string, paths: JournalPaths): string;
|
|
281
|
+
/**
|
|
282
|
+
* Following read, for `process.spawn` only. Never pass this to `exec`:
|
|
283
|
+
* `ProcessOptions` has no timeout, so a following `exec` blocks until the
|
|
284
|
+
* sandbox or the RPC times out.
|
|
285
|
+
*
|
|
286
|
+
* Deliberately pipes into NOTHING. `tail -f` flushes each append as it sees it,
|
|
287
|
+
* so it is the one stage in this pipeline that streams; adding any filter puts
|
|
288
|
+
* that filter's stdio buffer between the agent and the host and the follow
|
|
289
|
+
* strategy stops following (see rule 2 in the module doc for the measurements).
|
|
290
|
+
* The host turns these raw bytes into positioned lines with
|
|
291
|
+
* `journal-bytes.ts`.
|
|
292
|
+
*
|
|
293
|
+
* It also creates the journal before tailing it, because `tail -f` on a path
|
|
294
|
+
* that does not exist yet prints a diagnostic and EXITS rather than waiting —
|
|
295
|
+
* so the reader would deliver zero lines for a run whose journal simply had not
|
|
296
|
+
* been created yet. The reader and the agent are two independent spawns and
|
|
297
|
+
* nothing orders them, so that race is the normal case, not the unlucky one.
|
|
298
|
+
* `: >> file` is a builtin no-op plus an O_CREAT|O_APPEND open: it creates the
|
|
299
|
+
* file when absent and, critically, does NOT truncate one that already has a
|
|
300
|
+
* prefix a previous host already delivered. `;` rather than `&&` throughout, so
|
|
301
|
+
* a prep step that fails still lets the `tail` run and fail the way it used to
|
|
302
|
+
* rather than turning a read into a silent no-op. (`tail -F` would also retry,
|
|
303
|
+
* but `-F` is a GNU/busybox extension, not POSIX, and this file only emits
|
|
304
|
+
* POSIX shell.)
|
|
305
|
+
*/
|
|
306
|
+
export declare function journalFollowCommand(paths: JournalPaths, fromByte: number): string;
|
|
307
|
+
/**
|
|
308
|
+
* Bounded read: `-f` dropped so it always terminates, and base64-framed because
|
|
309
|
+
* it can be — `exec` closes `base64`'s stdin, which flushes it, and the whole
|
|
310
|
+
* result arrives as one already-complete `ExecResult.stdout` string. This is the
|
|
311
|
+
* Cloudflare path, whose `spawn` cannot be killed and whose `exec` drops the
|
|
312
|
+
* AbortSignal, making a following read unstoppable there.
|
|
313
|
+
*/
|
|
314
|
+
export declare function journalReadCommand(paths: JournalPaths, fromByte: number): string;
|
|
315
|
+
/**
|
|
316
|
+
* Existence probe. A shell `test -f`, not `handle.fs.exists`: see rule 3 in the
|
|
317
|
+
* module doc — on local-process the two resolve `/tmp` differently.
|
|
318
|
+
*/
|
|
319
|
+
export declare function journalExistsCommand(paths: Pick<JournalPaths, 'journal'>): string;
|
|
320
|
+
/**
|
|
321
|
+
* Bounded read of the stderr SIDECAR (not the journal), so a non-zero exit can
|
|
322
|
+
* carry the agent's own diagnostics instead of a bare exit code.
|
|
323
|
+
*
|
|
324
|
+
* `exec`-only, like {@link journalReadCommand}, and base64-framed for the same
|
|
325
|
+
* reason: `exec` closes the encoder's stdin so it flushes, and the frame keeps a
|
|
326
|
+
* provider that folds stderr into stdout from splicing its own text into the
|
|
327
|
+
* bytes. Unlike the journal, the sidecar is NOT line-delimited JSON — an agent
|
|
328
|
+
* writes whatever it likes there, including partial lines and raw control bytes
|
|
329
|
+
* — so framing is what makes it safe to hand to a single `ExecResult.stdout`.
|
|
330
|
+
*
|
|
331
|
+
* `tail -c -N` (the LAST N bytes) rather than the first: the read has to be
|
|
332
|
+
* bounded, because a runaway agent's sidecar can be arbitrarily large and this
|
|
333
|
+
* runs on the host, and a crash's cause is at the end of stderr, not the start.
|
|
334
|
+
* The cost is that the first character can be a truncated UTF-8 sequence; the
|
|
335
|
+
* caller decodes lossily rather than failing, since this text is diagnostic.
|
|
336
|
+
*/
|
|
337
|
+
export declare function journalStderrReadCommand(paths: JournalPaths, maxBytes?: number): string;
|
|
338
|
+
/**
|
|
339
|
+
* Delete both of a run's journal files.
|
|
340
|
+
*
|
|
341
|
+
* **Ordering is the whole contract here, not the `rm`.** This may only run once
|
|
342
|
+
* the run is TERMINAL — i.e. after the `{"__exit":N}` sentinel has been observed
|
|
343
|
+
* — and must never run on an abort. The three claims that make the deletion safe:
|
|
344
|
+
*
|
|
345
|
+
* 1. **Terminal means the event log holds the whole run.** The journal exists so
|
|
346
|
+
* a successor host can replay a run from byte 0 and re-derive the chunks a
|
|
347
|
+
* dead host never got to append. Once the sentinel has been read and the
|
|
348
|
+
* replay has been forwarded, the log — not the journal — is the record. A late
|
|
349
|
+
* takeover therefore aligns against the log: `align.ts`'s `alignToStoredLog`
|
|
350
|
+
* takes a `StreamDurability` and an `AsyncIterable<StreamChunk>`, has no
|
|
351
|
+
* `SandboxHandle` and no {@link JournalPaths} in its signature, and reads the
|
|
352
|
+
* prefix with `durability.snapshot()`. It *cannot* read the journal, so
|
|
353
|
+
* deleting one that is terminal cannot break it.
|
|
354
|
+
* 2. **A non-zero exit is terminal too.** `{"__exit":7}` is as final as
|
|
355
|
+
* `{"__exit":0}`; the run failed, it is not resumable, and the failure is
|
|
356
|
+
* already on its way to the client as a `RUN_ERROR`. Keeping a failed run's
|
|
357
|
+
* journal would leak exactly the runs most likely to be numerous.
|
|
358
|
+
* 3. **An abort is NOT terminal.** A consumer that stops early (lease lost,
|
|
359
|
+
* client gone, host shutting down) may be handing the run off to a successor
|
|
360
|
+
* host that still needs every byte, so an aborted read must leave both files
|
|
361
|
+
* alone.
|
|
362
|
+
*
|
|
363
|
+
* Shell `rm`, never `handle.fs.remove`: rule 3 in the module doc. On
|
|
364
|
+
* local-process `/tmp` resolves under the sandbox root through `fs.*` but to the
|
|
365
|
+
* host's real `/tmp` through the shell, so an `fs.remove` would delete a
|
|
366
|
+
* different path than the one `journaledCommand` wrote — i.e. nothing, silently.
|
|
367
|
+
*
|
|
368
|
+
* `-f` so a journal that is already gone (a provider that reaped `/tmp`, a
|
|
369
|
+
* successor that cleaned up first) is a success, not an error. Callers treat the
|
|
370
|
+
* whole thing as best effort regardless: a failed cleanup must never fail a run
|
|
371
|
+
* that has already completed.
|
|
372
|
+
*
|
|
373
|
+
* **What this does NOT bound:** a run that reaches its sentinel while DETACHED
|
|
374
|
+
* has no host reading its journal, so nothing ever observes the sentinel and
|
|
375
|
+
* nothing calls this. Bounding it is `pruneJournals`' job (`journal-sweep.ts`):
|
|
376
|
+
* a sweep over {@link DEFAULT_JOURNAL_DIR} that deletes only the journals whose
|
|
377
|
+
* runs the store says are terminal. It runs from a cron the application
|
|
378
|
+
* schedules, not from a run, so such a journal survives until that sweep — on a
|
|
379
|
+
* `keepAlive` sandbox, indefinitely without one.
|
|
380
|
+
*/
|
|
381
|
+
export declare function journalCleanupCommand(paths: JournalPaths): string;
|
|
382
|
+
/**
|
|
383
|
+
* List the journal directory, one entry per line.
|
|
384
|
+
*
|
|
385
|
+
* **`2>/dev/null` is load-bearing, not tidiness.** Daytona's `exec` folds
|
|
386
|
+
* stderr into stdout by contract and the Sprites fast path does the same, so on
|
|
387
|
+
* a directory that does not exist yet — the normal state before the first run —
|
|
388
|
+
* an `ls: cannot access '/tmp/tanstack-runs': No such file or directory`
|
|
389
|
+
* diagnostic would arrive as if it were a LINE OF OUTPUT. The sweep would then
|
|
390
|
+
* hand that sentence to {@link decodeJournalRunId} and, if it decoded, delete
|
|
391
|
+
* whatever it named. Silencing it inside the sandbox means a missing directory
|
|
392
|
+
* produces zero lines, which is the truth.
|
|
393
|
+
*
|
|
394
|
+
* `-1` so one entry occupies one line: `ls` only defaults to columns on a tty,
|
|
395
|
+
* but `exec`'s stdout is not always a pipe on every provider and the flag costs
|
|
396
|
+
* nothing.
|
|
397
|
+
*
|
|
398
|
+
* **Dot-files are not listed**, by `ls` default. A runId beginning with `.`
|
|
399
|
+
* encodes to a hidden filename (`.` passes through the encoder), so its journal
|
|
400
|
+
* is invisible to a sweep and leaks rather than being deleted. That is the safe
|
|
401
|
+
* direction of the two and the reason this is documented rather than fixed with
|
|
402
|
+
* `-a`, which would also introduce `.` and `..` as entries.
|
|
403
|
+
*/
|
|
404
|
+
export declare function journalListCommand(dir?: string): string;
|
|
405
|
+
/** One listed journal file with its modification time. */
|
|
406
|
+
export interface JournalDirEntry {
|
|
407
|
+
/** Filename as listed, extension included; feed to {@link decodeJournalRunId}. */
|
|
408
|
+
name: string;
|
|
409
|
+
/** Modification time in milliseconds since the epoch. */
|
|
410
|
+
mtimeMs: number;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Outcome of {@link parseJournalMtimeListing}. Deliberately NOT an array: see
|
|
414
|
+
* that function's doc for why an empty list must not be the failure value.
|
|
415
|
+
*/
|
|
416
|
+
export type JournalMtimeListing =
|
|
417
|
+
/** The mechanism ran. `entries` is complete — possibly, and meaningfully, empty. */
|
|
418
|
+
{
|
|
419
|
+
kind: 'listed';
|
|
420
|
+
entries: Array<JournalDirEntry>;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The listing did not run (no `stat -c`, or the directory is absent). Nothing
|
|
424
|
+
* is known about the directory's contents — in particular NOT that it is
|
|
425
|
+
* empty, and NOT that anything in it is old.
|
|
426
|
+
*/
|
|
427
|
+
| {
|
|
428
|
+
kind: 'unavailable';
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* List the journal directory WITH modification times, so a sweep can leave
|
|
432
|
+
* recently-touched journals alone.
|
|
433
|
+
*
|
|
434
|
+
* **Neither `find -newermt` nor `find -printf` may be used here.** Both are GNU
|
|
435
|
+
* extensions, absent from BusyBox 1.37 — the `alpine:3` shell every docker-
|
|
436
|
+
* provider journal test runs in — and absent from MINGW64's `find`. Measured
|
|
437
|
+
* working on BusyBox 1.37, GNU coreutils, and MINGW64: `stat -c "%Y %n"`, which
|
|
438
|
+
* is what this emits. (`touch -d <ts> ref` plus `find ! -newer ref` also works
|
|
439
|
+
* on all three, but it needs a writable reference file OUTSIDE the journal
|
|
440
|
+
* directory — inside, `ls -1` would report the reference as an entry — and a
|
|
441
|
+
* write is a side effect this pure-composition module has no business having.)
|
|
442
|
+
*
|
|
443
|
+
* **The directory is passed as its own first operand on purpose.** It is a
|
|
444
|
+
* self-witness. `stat` reports every operand it can and only *then* exits
|
|
445
|
+
* non-zero, so:
|
|
446
|
+
*
|
|
447
|
+
* - populated directory → witness line + one line per file, exit 0
|
|
448
|
+
* - EMPTY directory → witness line only, exit 1 (the unexpanded glob is an
|
|
449
|
+
* operand `stat` cannot stat)
|
|
450
|
+
* - `stat` without `-c` support → NO output at all, exit 1
|
|
451
|
+
*
|
|
452
|
+
* That is what makes "no files" distinguishable from "the mechanism is
|
|
453
|
+
* unavailable", and it has to be distinguishable because BusyBox exits 1 with
|
|
454
|
+
* EMPTY stdout on an unrecognised flag. A caller that ignored the exit code and
|
|
455
|
+
* took an empty parse as an empty directory would conclude every journal is
|
|
456
|
+
* absent; one that then inferred "therefore nothing is recent" would delete the
|
|
457
|
+
* whole directory. Hence {@link parseJournalMtimeListing} returns
|
|
458
|
+
* `{ kind: 'unavailable' }` rather than `[]`, and the exit code is not consulted
|
|
459
|
+
* at all — the witness line, not the status, is the evidence.
|
|
460
|
+
*
|
|
461
|
+
* Note the glob shares `ls`'s dot-file blindness (same fail-safe consequence),
|
|
462
|
+
* and that `stat` cannot distinguish a file from a subdirectory here; a stray
|
|
463
|
+
* subdirectory is caught downstream, because its name will not decode.
|
|
464
|
+
*/
|
|
465
|
+
export declare function journalMtimeListCommand(dir?: string): string;
|
|
466
|
+
/**
|
|
467
|
+
* Parse {@link journalMtimeListCommand}'s stdout.
|
|
468
|
+
*
|
|
469
|
+
* Line-based, space-split parsing is unambiguous here: an encoded filename can
|
|
470
|
+
* only contain `[A-Za-z0-9.-]` and `_hh` escapes (see {@link encodeRunId}), so
|
|
471
|
+
* it can never contain a space or a newline, and `%Y` is digits. A line that
|
|
472
|
+
* does not fit the shape — including a directory prefix that is not `dir` — is
|
|
473
|
+
* dropped rather than guessed at.
|
|
474
|
+
*/
|
|
475
|
+
export declare function parseJournalMtimeListing(text: string, dir?: string): JournalMtimeListing;
|
|
476
|
+
/**
|
|
477
|
+
* Bounded read of the END of a run's journal, purely to learn whether the agent
|
|
478
|
+
* reached its `{"__exit":N}` sentinel.
|
|
479
|
+
*
|
|
480
|
+
* **This exists so a reaper does not have to drive the run to find out.**
|
|
481
|
+
* Entering `pipeToRunLog` to check writes a terminal status and calls
|
|
482
|
+
* `durability.close()` on every path, including for a healthy mid-flight run —
|
|
483
|
+
* recording it as `'completed'`, which drops it out of `listReclaimable`
|
|
484
|
+
* forever. This probe is read-only and provider-neutral, and it is what makes a
|
|
485
|
+
* reclaim candidate safe to drive.
|
|
486
|
+
*
|
|
487
|
+
* The command is the byte-identical idiom to {@link journalStderrReadCommand},
|
|
488
|
+
* pointed at the journal instead of the sidecar: `tail -c -N` (the LAST N
|
|
489
|
+
* bytes, because the sentinel is at the end), `2>/dev/null` so a missing
|
|
490
|
+
* journal cannot splice a diagnostic into the bytes on a provider that folds
|
|
491
|
+
* stderr into stdout, and base64 framing. Verified on BusyBox 1.37.
|
|
492
|
+
*
|
|
493
|
+
* base64 is correct HERE and forbidden on {@link journalFollowCommand} for the
|
|
494
|
+
* reason rule 2 in the module doc measures: the encoder fully buffers a piped
|
|
495
|
+
* stdout, which is harmless when `exec` closes its stdin and fatal when the
|
|
496
|
+
* producer is `tail -f`. This read is bounded and terminates, so it never
|
|
497
|
+
* streams.
|
|
498
|
+
*/
|
|
499
|
+
export declare function journalExitProbeCommand(paths: JournalPaths, maxBytes?: number): string;
|
|
500
|
+
/**
|
|
501
|
+
* Is ONE journal line this run's genuine exit sentinel? The exit code if so,
|
|
502
|
+
* `null` for anything else — including a line that carries
|
|
503
|
+
* {@link EXIT_SENTINEL_KEY} but not this run's nonce, which is agent output and
|
|
504
|
+
* nothing more.
|
|
505
|
+
*
|
|
506
|
+
* FAIL CLOSED at every step, because the consumers of a non-`null` answer stop
|
|
507
|
+
* the run and reclaim its sandbox:
|
|
508
|
+
*
|
|
509
|
+
* - not JSON, or not an object → `null`. This is also what absorbs the partial
|
|
510
|
+
* first line a byte-bounded `tail -c -N` can start in the middle of.
|
|
511
|
+
* - no `__nonce`, or a `__nonce` that is not exactly `paths.nonce` → `null`. An
|
|
512
|
+
* agent line cannot be told from the shell's without this (see
|
|
513
|
+
* {@link EXIT_SENTINEL_NONCE_KEY}).
|
|
514
|
+
* - a matching nonce but a non-integer `__exit` → `null`, NOT `0`. The old code
|
|
515
|
+
* coerced a non-number to `0`, which turned a garbled sentinel into a reported
|
|
516
|
+
* SUCCESS. Nothing that reaches here legitimately can be non-integer: the only
|
|
517
|
+
* writer is `printf '…%d…' "$?"`.
|
|
518
|
+
*
|
|
519
|
+
* Exported so the streaming reader (`runner.ts`) applies exactly the same test,
|
|
520
|
+
* line by line, that the reaper's bounded tail probe applies — one definition of
|
|
521
|
+
* "the run ended", not two that can drift.
|
|
522
|
+
*/
|
|
523
|
+
export declare function parseExitSentinel(line: string, paths: JournalPaths): number | null;
|
|
524
|
+
/**
|
|
525
|
+
* Find the exit sentinel in a decoded journal tail; `null` when it is absent,
|
|
526
|
+
* which is the mid-flight (or never-started) case.
|
|
527
|
+
*
|
|
528
|
+
* **Scanned from the END, and the nonce is REQUIRED.** Both matter, and both are
|
|
529
|
+
* corrections:
|
|
530
|
+
*
|
|
531
|
+
* - The shell appends the real sentinel AFTER the command's own output, so the
|
|
532
|
+
* genuine one is always the last matching line in the window. Taking the first
|
|
533
|
+
* match let an agent line that happened to look like a sentinel win over the
|
|
534
|
+
* truth that followed it.
|
|
535
|
+
* - `paths.nonce` must match, or the line is not a sentinel at all. Without that,
|
|
536
|
+
* a mid-flight run whose agent printed any JSON object containing `__exit` read
|
|
537
|
+
* as `finished`, and the reaper destroyed a live sandbox on the strength of it.
|
|
538
|
+
*
|
|
539
|
+
* `paths` rather than a bare nonce string so callers pass the object they already
|
|
540
|
+
* hold and cannot pair a tail with another run's nonce.
|
|
541
|
+
*/
|
|
542
|
+
export declare function parseJournalExit(text: string, paths: JournalPaths): number | null;
|