@zocomputer/agent-sdk 0.5.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
@@ -0,0 +1,487 @@
1
+ import type { Readable } from "node:stream";
2
+ import { Client, type ClientChannel } from "ssh2";
3
+ import type { SandboxSession } from "eve/sandbox";
4
+ import { extractLines } from "@ai-sdk/provider-utils";
5
+ import { decodeText, encodeText, resolveSandboxPath, shellSingleQuote } from "./pure";
6
+ import { SshConnectionManager, type SshSandboxAccess } from "./ssh-connection";
7
+ import { awaitCommand } from "./ssh-exec";
8
+ import { removePath as sftpRemovePath, sftpReadBytes, sftpWriteBytes } from "./sftp";
9
+
10
+ // An eve `SandboxSession` backed by an SSH connection to the sandbox, using the
11
+ // scoped, short-lived token the control plane (apps/api) minted. The runtime
12
+ // holds NO Daytona key — only this token — so a leak exposes one sandbox for a
13
+ // few minutes, not the org (see plans/rc2/sandbox-per-session.md threat model).
14
+ //
15
+ // The SSH token is short-lived (~10 min), so the connection is established
16
+ // LAZILY per call and re-provisioned (fresh token via `acquireAccess`) when it
17
+ // has dropped or the token is near expiry — long sessions don't fail when the
18
+ // first token expires.
19
+ //
20
+ // Implements `run` (exec), `spawn` (long-running process), and the file I/O
21
+ // methods (byte/binary/text read+write, removePath) over SSH — exec for
22
+ // commands/dir ops, SFTP for byte transfer (see ./sftp). eve does not expose
23
+ // `buildSandboxSession`, so we construct the full public surface ourselves.
24
+ // `setNetworkPolicy` is set at provision time by the control plane, not here.
25
+
26
+ // `SshSandboxAccess` (the scoped credential) lives with the connection lifecycle
27
+ // in ./ssh-connection; re-export it so existing importers (api-client, zo-backend)
28
+ // are unchanged.
29
+ export type { SshSandboxAccess };
30
+
31
+ /**
32
+ * Work dir relative paths anchor to. eve's `SandboxSession` contract nominally
33
+ * resolves relative paths from `/workspace`, but on Daytona's default image
34
+ * `/workspace` doesn't exist and the sandbox user (`daytona`) can't create it
35
+ * (it's a root-owned mount point — verified: `mkdir /workspace` → permission
36
+ * denied). So we anchor to the user's home, which is where SSH lands and is
37
+ * writable. The divergence only matters if an authored agent hardcodes the
38
+ * literal `/workspace`; surfacing a real writable workspace at that path would
39
+ * need a base image that pre-creates + chowns it (a snapshot/bootstrap follow-up).
40
+ */
41
+ const WORK_DIR = "/home/daytona";
42
+
43
+ /** Re-mint a token this many ms before it actually expires (clock skew + RTT). */
44
+ const EXPIRY_SKEW_MS = 30_000;
45
+
46
+ /**
47
+ * SSH port for Daytona's gateway. Daytona's `sshCommand` is always
48
+ * `ssh <token>@<host>` with no `-p`, i.e. the default port 22 — the host
49
+ * (`access.sshHost`) is what varies, not the port. Named here rather than
50
+ * inlined so it's one obvious place to change if that ever stops holding.
51
+ */
52
+ const SSH_PORT = 22;
53
+
54
+
55
+ /** Open an SSH connection to the sandbox with the scoped token. */
56
+ export function connectSsh(access: SshSandboxAccess): Promise<Client> {
57
+ const conn = new Client();
58
+ return new Promise((resolve, reject) => {
59
+ conn
60
+ .on("ready", () => resolve(conn))
61
+ .on("error", reject)
62
+ .connect({
63
+ host: access.sshHost,
64
+ port: SSH_PORT,
65
+ username: access.sshUser,
66
+ // The token IS the credential; no password/key auth.
67
+ tryKeyboard: false,
68
+ });
69
+ });
70
+ }
71
+
72
+
73
+ /**
74
+ * Run one command over the SSH connection, collecting stdout/stderr + exit code.
75
+ *
76
+ * Status comes from ssh2's `exit` event `(code, signal)`, falling back to the
77
+ * args `close` carries. A signal (OOM / timeout SIGKILL) reports a non-zero code
78
+ * + a note on stderr — never a clean exit 0 the agent would mistake for success.
79
+ * `abortSignal` closes the stream to cancel; a stream error rejects. (env is
80
+ * inlined into `command` by the caller — see `anchored` — not passed here.)
81
+ *
82
+ * KNOWN LIMITATION (follow-up): ssh2 doesn't guarantee `exit` fires before
83
+ * `close`, and for instant-completing commands it sometimes delivers neither a
84
+ * code nor a signal — so a fast failing command can report exit 0. Long-running
85
+ * commands (the case that matters for OOM/timeout) reliably carry their status.
86
+ * The deterministic fix is to wrap the command to print its own `$?` and parse
87
+ * it from stdout; deferred with the other runtime follow-ups (see plan).
88
+ */
89
+ function runOverSsh(
90
+ conn: Client,
91
+ command: string,
92
+ options: { abortSignal?: AbortSignal | undefined } = {},
93
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
94
+ return new Promise((resolve, reject) => {
95
+ conn.exec(command, (err, stream) => {
96
+ if (err) { reject(err); return; }
97
+ let stdout = "";
98
+ let stderr = "";
99
+ stream
100
+ .on("data", (d: Buffer) => (stdout += d.toString()))
101
+ .stderr.on("data", (d: Buffer) => (stderr += d.toString()));
102
+ // awaitCommand owns exit reconciliation + abort precedence; we just add the
103
+ // collected output and a stderr note when a signal killed the command.
104
+ awaitCommand(stream, options.abortSignal).then(
105
+ ({ exitCode, signal }) =>
106
+ resolve({
107
+ exitCode,
108
+ stdout,
109
+ stderr: signal != null ? `${stderr}\n[killed by signal ${signal}]` : stderr,
110
+ }),
111
+ reject,
112
+ );
113
+ });
114
+ });
115
+ }
116
+
117
+ // --- helpers for the file + spawn surface ---
118
+
119
+ /**
120
+ * Build the shell command run + spawn actually send: an optional per-command
121
+ * `env` prefix, the work-dir anchoring prelude, then the command.
122
+ *
123
+ * - **env** is inlined as `VAR='val' …` prefixes (values shell-quoted) rather
124
+ * than ssh2's SSH `env` channel request, which Daytona's sshd silently drops
125
+ * (it isn't `AcceptEnv`-listed) — so inlining is what actually sets them.
126
+ * - **work dir**: SSH lands in $HOME, not /workspace (which doesn't exist by
127
+ * default), so we `cd` into the resolved dir — relative paths then resolve
128
+ * from WORK_DIR per eve's contract, and `mkdir -p` makes it exist on first
129
+ * use. The dir is shell-quoted; the command itself is intentionally shell.
130
+ */
131
+ function anchored(
132
+ command: string,
133
+ workingDirectory: string | undefined,
134
+ env: Record<string, string> | undefined,
135
+ ): string {
136
+ const dir = shellSingleQuote(resolveSandboxPath(WORK_DIR, workingDirectory ?? "."));
137
+ const envPrefix =
138
+ env === undefined || Object.keys(env).length === 0
139
+ ? ""
140
+ : `${Object.entries(env)
141
+ .map(([k, v]) => {
142
+ // A `KEY=val` prefix can't safely quote the KEY (it's shell syntax,
143
+ // not a word), so reject anything that isn't a valid POSIX env name
144
+ // rather than let a metacharacter-laden key inject. Values ARE quoted.
145
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
146
+ throw new Error(`zo sandbox: invalid environment variable name ${JSON.stringify(k)}`);
147
+ }
148
+ return `${k}=${shellSingleQuote(v)}`;
149
+ })
150
+ .join(" ")} `;
151
+ return `mkdir -p ${dir} && cd ${dir} && ${envPrefix}${command}`;
152
+ }
153
+
154
+ /** Throw the abort reason if already aborted (the cheap upfront cancellation check). */
155
+ function throwIfAborted(signal: AbortSignal | undefined): void {
156
+ if (signal?.aborted) throw signal.reason ?? new Error("operation aborted");
157
+ }
158
+
159
+ /** Wrap raw bytes as a one-chunk web ReadableStream (the lowest-level read primitive). */
160
+ function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
161
+ return new ReadableStream<Uint8Array>({
162
+ start(controller) {
163
+ controller.enqueue(bytes);
164
+ controller.close();
165
+ },
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Collect a web ReadableStream of bytes into one Uint8Array, honoring abort.
171
+ * Each read is RACED against the abort signal, so an abort that fires while a
172
+ * read is pending (a slow/blocked producer) cancels the reader immediately
173
+ * rather than waiting for that read to resolve first.
174
+ */
175
+ export async function streamToBytes(
176
+ stream: ReadableStream<Uint8Array>,
177
+ abortSignal?: AbortSignal,
178
+ ): Promise<Uint8Array> {
179
+ const chunks: Uint8Array[] = [];
180
+ let total = 0;
181
+ const reader = stream.getReader();
182
+ const abortReason = (): Error =>
183
+ (abortSignal?.reason instanceof Error ? abortSignal.reason : null) ??
184
+ new Error("write aborted");
185
+
186
+ // A promise that rejects the moment the signal aborts (or is already aborted).
187
+ let onAbort: (() => void) | undefined;
188
+ const aborted = new Promise<never>((_, reject) => {
189
+ if (abortSignal === undefined) return;
190
+ if (abortSignal.aborted) { reject(abortReason()); return; }
191
+ onAbort = () => reject(abortReason());
192
+ abortSignal.addEventListener("abort", onAbort, { once: true });
193
+ });
194
+
195
+ try {
196
+ for (;;) {
197
+ const { done, value } =
198
+ abortSignal === undefined
199
+ ? await reader.read()
200
+ : await Promise.race([reader.read(), aborted]);
201
+ if (done) break;
202
+ chunks.push(value);
203
+ total += value.length;
204
+ }
205
+ } catch (e) {
206
+ await reader.cancel(e); // abort (or a read error) → release the producer
207
+ throw e;
208
+ } finally {
209
+ if (onAbort !== undefined) abortSignal?.removeEventListener("abort", onAbort);
210
+ // Swallow the unraced abort rejection so it can't surface as unhandled.
211
+ aborted.catch(() => {});
212
+ }
213
+ const out = new Uint8Array(total);
214
+ let offset = 0;
215
+ for (const chunk of chunks) {
216
+ out.set(chunk, offset);
217
+ offset += chunk.length;
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Node Readable (ssh2 stdout/stderr) → web ReadableStream<Uint8Array>, with:
224
+ * - backpressure: pause the source when the queue fills, resume in pull(), so a
225
+ * process that out-produces its consumer doesn't buffer unboundedly;
226
+ * - cancellation: if the consumer cancels, destroy the source so ssh2 stops
227
+ * emitting.
228
+ *
229
+ * A single `done` latch makes the controller terminal-safe by construction: once
230
+ * end/error/cancel fires, no further enqueue/close/error happens. ssh2 can emit
231
+ * a `data` after `end`/`error`, and enqueuing on a settled controller throws —
232
+ * so `data` both checks the latch and is wrapped, never throwing into ssh2's
233
+ * emitter (which would surface as an uncaught error).
234
+ */
235
+ export function nodeToWebStream(node: Readable): ReadableStream<Uint8Array> {
236
+ let done = false;
237
+ return new ReadableStream<Uint8Array>({
238
+ start(controller) {
239
+ node.on("data", (d: Buffer) => {
240
+ if (done) return;
241
+ try {
242
+ controller.enqueue(new Uint8Array(d));
243
+ } catch {
244
+ // Controller already closed/errored between the latch check and here —
245
+ // stop reading rather than let the throw escape into ssh2's emitter.
246
+ done = true;
247
+ node.destroy();
248
+ return;
249
+ }
250
+ // Negative desiredSize → the consumer is behind; stop pulling from ssh2
251
+ // until pull() asks for more.
252
+ if ((controller.desiredSize ?? 1) <= 0) node.pause();
253
+ });
254
+ node.on("end", () => {
255
+ if (done) return;
256
+ done = true;
257
+ controller.close();
258
+ });
259
+ node.on("error", (e: Error) => {
260
+ if (done) return;
261
+ done = true;
262
+ controller.error(e);
263
+ });
264
+ },
265
+ pull() {
266
+ node.resume();
267
+ },
268
+ cancel() {
269
+ done = true;
270
+ node.destroy();
271
+ },
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Spawn a long-running process over exec, returning eve's `SandboxProcess`:
277
+ * live stdout/stderr streams, `wait()` for the exit code, `kill()`.
278
+ *
279
+ * Honors `abortSignal` (abort → kill the process AND make `wait()` reject with
280
+ * the abort reason). env is inlined into `command` by the caller (see
281
+ * `anchored`), not passed to the exec channel.
282
+ */
283
+ function spawnOverSsh(
284
+ conn: Client,
285
+ command: string,
286
+ options: { abortSignal?: AbortSignal | undefined } = {},
287
+ ): Promise<{
288
+ stdout: ReadableStream<Uint8Array>;
289
+ stderr: ReadableStream<Uint8Array>;
290
+ wait: () => Promise<{ exitCode: number }>;
291
+ kill: () => Promise<void>;
292
+ }> {
293
+ return new Promise((resolve, reject) => {
294
+ conn.exec(command, (err, stream) => {
295
+ if (err) { reject(err); return; }
296
+ resolve(buildSpawnedProcess(stream, options));
297
+ });
298
+ });
299
+ }
300
+
301
+ /** A spawned process's `SandboxProcess` surface (eve's contract). */
302
+ export interface SpawnedProcess {
303
+ stdout: ReadableStream<Uint8Array>;
304
+ stderr: ReadableStream<Uint8Array>;
305
+ wait: () => Promise<{ exitCode: number }>;
306
+ kill: () => Promise<void>;
307
+ }
308
+
309
+ /**
310
+ * Wrap a live exec channel as a `SandboxProcess`. Split out from the `conn.exec`
311
+ * plumbing so the lifecycle (kill / wait / signal / abort / channel-error /
312
+ * unhandled-rejection) is unit-testable with a fake channel (no live SSH).
313
+ */
314
+ export function buildSpawnedProcess(
315
+ stream: ClientChannel,
316
+ options: { abortSignal?: AbortSignal | undefined } = {},
317
+ ): SpawnedProcess {
318
+ // kill() best-effort signals the channel, then closes it — many SSH gateways
319
+ // (incl. Daytona's) ignore an exit-signal request, so closing the channel is
320
+ // what actually tears the process down. Idempotent + never throws on an
321
+ // already-closed channel.
322
+ let killed = false;
323
+ const kill = (): Promise<void> => {
324
+ if (!killed) {
325
+ killed = true;
326
+ try {
327
+ stream.signal("KILL");
328
+ stream.close();
329
+ } catch {
330
+ // already closed — nothing to do
331
+ }
332
+ }
333
+ return Promise.resolve();
334
+ };
335
+
336
+ // awaitCommand owns exit reconciliation AND abort precedence (it closes the
337
+ // channel on abort and rejects with the reason, winning a same-tick exit), so
338
+ // wait() is just a view of it. A caller may never call wait(), so attach a
339
+ // benign handler too — a channel error must not become an unhandled rejection.
340
+ const exit = awaitCommand(stream, options.abortSignal);
341
+ exit.catch(() => {});
342
+ const wait = (): Promise<{ exitCode: number }> =>
343
+ exit.then(({ exitCode }) => ({ exitCode }));
344
+
345
+ return {
346
+ stdout: nodeToWebStream(stream),
347
+ stderr: nodeToWebStream(stream.stderr),
348
+ wait,
349
+ kill,
350
+ };
351
+ }
352
+
353
+
354
+ /** A lazily-connected SSH session plus lifecycle helpers for the backend. */
355
+ export interface SshSession {
356
+ readonly session: SandboxSession;
357
+ /** The provider sandbox id once known (after first provision), else `null`. */
358
+ currentSandboxId(): string | null;
359
+ /** Close the SSH connection (does NOT destroy the sandbox). */
360
+ dispose(): void;
361
+ }
362
+
363
+ /**
364
+ * Build an eve `SandboxSession`, lazily provisioning + connecting over SSH.
365
+ *
366
+ * `acquireAccess` mints fresh scoped access (the runtime's API call); it's
367
+ * called on first use and again whenever the token has expired or the
368
+ * connection dropped, so a long session keeps working past the token TTL. The
369
+ * sandbox is provisioned only when a tool first calls `run` — never just to
370
+ * open a session the agent might not use.
371
+ */
372
+ export function sshSandboxSession(
373
+ /** Stable id surfaced as `SandboxSession.id` (the eve session key). */
374
+ id: string,
375
+ acquireAccess: () => Promise<SshSandboxAccess>,
376
+ ): SshSession {
377
+ const resolvePath = (p: string): string => resolveSandboxPath(WORK_DIR, p);
378
+
379
+ // The connection lifecycle (reuse / reconnect / re-mint / dispose) lives in
380
+ // the manager; here we only wrap the real ssh2 `Client` as a ManagedConn.
381
+ const manager = new SshConnectionManager<Client>({
382
+ acquireAccess,
383
+ expirySkewMs: EXPIRY_SKEW_MS,
384
+ connect: async (access) => {
385
+ const client = await connectSsh(access);
386
+ return {
387
+ client,
388
+ end: () => client.end(),
389
+ onClose: (cb) => void client.on("close", cb),
390
+ };
391
+ },
392
+ });
393
+
394
+ const session: SandboxSession = {
395
+ id,
396
+ resolvePath,
397
+
398
+ async run({ command, workingDirectory, env, abortSignal }) {
399
+ const c = (await manager.ensure()).client;
400
+ // env is inlined into the command (see anchored), not the SSH env channel.
401
+ return await runOverSsh(c, anchored(command, workingDirectory, env), { abortSignal });
402
+ },
403
+
404
+ async spawn({ command, workingDirectory, env, abortSignal }) {
405
+ const c = (await manager.ensure()).client;
406
+ return spawnOverSsh(c, anchored(command, workingDirectory, env), { abortSignal });
407
+ },
408
+
409
+ // --- file reads: resolve null when the file is absent (eve contract) ---
410
+ // abortSignal is an UPFRONT check only: SFTP has no clean per-op cancel and
411
+ // these ops are sub-second, so a mid-flight abort isn't honored (documented).
412
+ async readBinaryFile({ path: p, abortSignal }) {
413
+ throwIfAborted(abortSignal);
414
+ const c = (await manager.ensure()).client;
415
+ return await sftpReadBytes(c, resolvePath(p));
416
+ },
417
+ async readFile({ path: p, abortSignal }) {
418
+ const bytes = await this.readBinaryFile({
419
+ path: p,
420
+ ...(abortSignal !== undefined ? { abortSignal } : {}),
421
+ });
422
+ return bytes === null ? null : bytesToStream(bytes);
423
+ },
424
+ async readTextFile({ path: p, encoding, startLine, endLine, abortSignal }) {
425
+ const bytes = await this.readBinaryFile({
426
+ path: p,
427
+ ...(abortSignal !== undefined ? { abortSignal } : {}),
428
+ });
429
+ if (bytes === null) return null;
430
+ const text = decodeText(bytes, encoding);
431
+ // Line-range slicing is the AI-SDK's own extractLines (the exact impl eve
432
+ // uses) — we don't reimplement it. No bounds → returns text unchanged.
433
+ return startLine === undefined && endLine === undefined
434
+ ? text
435
+ : extractLines({
436
+ text,
437
+ ...(startLine !== undefined ? { startLine } : {}),
438
+ ...(endLine !== undefined ? { endLine } : {}),
439
+ });
440
+ },
441
+
442
+ // --- file writes: create parent dirs + overwrite (eve contract) ---
443
+ async writeBinaryFile({ path: p, content, abortSignal }) {
444
+ throwIfAborted(abortSignal);
445
+ const c = (await manager.ensure()).client;
446
+ await sftpWriteBytes(c, resolvePath(p), content);
447
+ },
448
+ async writeFile({ path: p, content, abortSignal }) {
449
+ // Check before consuming the (possibly large) stream, and let an abort
450
+ // mid-read cancel it rather than draining the whole thing first.
451
+ throwIfAborted(abortSignal);
452
+ const bytes = await streamToBytes(content, abortSignal);
453
+ await this.writeBinaryFile({
454
+ path: p,
455
+ content: bytes,
456
+ ...(abortSignal !== undefined ? { abortSignal } : {}),
457
+ });
458
+ },
459
+ async writeTextFile({ path: p, content, encoding, abortSignal }) {
460
+ await this.writeBinaryFile({
461
+ path: p,
462
+ content: encodeText(content, encoding),
463
+ ...(abortSignal !== undefined ? { abortSignal } : {}),
464
+ });
465
+ },
466
+
467
+ async removePath({ path: p, recursive, force, abortSignal }) {
468
+ throwIfAborted(abortSignal);
469
+ const c = (await manager.ensure()).client;
470
+ await sftpRemovePath(c, resolvePath(p), { recursive, force });
471
+ },
472
+
473
+ // Network policy is a provision-time concern owned by the control plane, not
474
+ // something the runtime sets per-session over SSH.
475
+ setNetworkPolicy: () => {
476
+ throw new Error(
477
+ "zo sandbox: setNetworkPolicy() is not supported from the runtime — network policy is set when the control plane provisions the sandbox.",
478
+ );
479
+ },
480
+ };
481
+
482
+ return {
483
+ session,
484
+ currentSandboxId: () => manager.currentSandboxId(),
485
+ dispose: () => manager.dispose(),
486
+ };
487
+ }
@@ -0,0 +1,88 @@
1
+ import type {
2
+ SandboxBackend,
3
+ SandboxBackendCreateInput,
4
+ SandboxBackendHandle,
5
+ SandboxBackendPrewarmInput,
6
+ SandboxSession,
7
+ } from "eve/sandbox";
8
+ import { requestScratchSandboxAccess } from "./api-client";
9
+ import { type SshSandboxAccess, sshSandboxSession } from "./ssh-session";
10
+ import { type DaytonaSessionMetadata, readSandboxId } from "./pure";
11
+
12
+ // The Zo sandbox backend for eve. It holds NO provider key: on `create` it
13
+ // resolves this session's `scratch` sandbox through the control-plane state
14
+ // broker (POST /state/handles) and gets back scoped SSH access, then connects
15
+ // over SSH. The default per-session sandbox is now the `scratch` external-state
16
+ // declaration (design-doc D10) — the same broker every other state uses. eve
17
+ // persists the sandbox id in its per-session metadata and hands it back as
18
+ // `existingMetadata` on a reply; the broker keys the session-partitioned
19
+ // instance off the eve session key, so a reply reattaches the same sandbox.
20
+ // `dispose` closes the SSH connection but never destroys the sandbox — Daytona
21
+ // idles it out and the next reply reattaches. See
22
+ // plans/dcosson/external-state-sandbox.md and plans/rc2/sandbox-per-session.md.
23
+
24
+ /** Backend name; participates in eve's reconnect-state matching. */
25
+ const BACKEND_NAME = "zo";
26
+
27
+ export interface ZoBackendOptions {
28
+ /** Control-plane API base URL the runtime calls (e.g. http://api.zo.localhost:4000). */
29
+ readonly apiBaseUrl: string;
30
+ }
31
+
32
+ export function zoBackend(options: ZoBackendOptions): SandboxBackend {
33
+ return {
34
+ name: BACKEND_NAME,
35
+
36
+ // None of these await directly — the real I/O is the SSH connect/exec
37
+ // inside `sshSandboxSession`'s lazily-called `acquireAccess`, plumbed
38
+ // through as a callback rather than awaited here — so they return an
39
+ // already-resolved Promise instead of using `async` with no `await`.
40
+ create(input: SandboxBackendCreateInput): Promise<SandboxBackendHandle> {
41
+ // The sandbox id eve remembered, if any — kept only for eve's own
42
+ // reconnect state (captureState below). The API doesn't take it: it keys
43
+ // this session's sandbox off the eve session key + caller's org itself.
44
+ const rememberedId = readSandboxId(input.existingMetadata);
45
+
46
+ // Resolve (or re-resolve) scoped SSH access from the control-plane state
47
+ // broker — the `scratch` sandbox declaration for this eve session. Called
48
+ // LAZILY on first `run` — so opening a session the agent never uses
49
+ // provisions nothing — and again when the short-lived token expires or the
50
+ // connection drops, so a long session keeps working.
51
+ const acquireAccess = async (): Promise<SshSandboxAccess> =>
52
+ await requestScratchSandboxAccess({
53
+ apiBaseUrl: options.apiBaseUrl,
54
+ eveSessionKey: input.sessionKey,
55
+ });
56
+
57
+ const ssh = sshSandboxSession(input.sessionKey, acquireAccess);
58
+
59
+ // No per-session options for MVP, so `use()` just yields the session.
60
+ const useSessionFn = (): Promise<SandboxSession> => Promise.resolve(ssh.session);
61
+
62
+ return Promise.resolve({
63
+ session: ssh.session,
64
+ useSessionFn,
65
+ captureState: () =>
66
+ Promise.resolve({
67
+ backendName: BACKEND_NAME,
68
+ sessionKey: input.sessionKey,
69
+ // The id we've provisioned this session, else the one eve remembered
70
+ // (a session that never ran a command has nothing new to persist).
71
+ metadata: {
72
+ daytonaSandboxId: ssh.currentSandboxId() ?? rememberedId ?? "",
73
+ } satisfies DaytonaSessionMetadata,
74
+ }),
75
+ // Close the local SSH connection; never destroy the sandbox (the next
76
+ // reply reattaches via the captured id).
77
+ dispose: () => {
78
+ ssh.dispose();
79
+ return Promise.resolve();
80
+ },
81
+ });
82
+ },
83
+
84
+ prewarm(_input: SandboxBackendPrewarmInput): Promise<{ reused: boolean }> {
85
+ return Promise.resolve({ reused: false });
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,39 @@
1
+ import { defineSandbox, type SandboxDefinition } from "eve/sandbox";
2
+ import { zoBackend } from "./zo-backend";
3
+
4
+ // Author-facing helper for an agent's `agent/sandbox.ts`. Agents write
5
+ // `export default zoSandbox()` and never name the provider — the built-in
6
+ // sandbox is Daytona-backed today, but the runtime never talks to Daytona or
7
+ // holds its key: it asks the Zo control plane (apps/api) to provision its
8
+ // sandbox and gets back scoped SSH access. The provider is an implementation
9
+ // detail behind that seam.
10
+
11
+ /**
12
+ * Where the runtime reaches the control plane to provision its sandbox.
13
+ * Defaults to `ZO_API_URL`; that's the only thing the runtime needs — never the
14
+ * Daytona key, which lives only in the control plane.
15
+ */
16
+ const DEFAULT_API_URL = "http://api.zo.localhost:4000";
17
+
18
+ /** Options for the Zo built-in sandbox. Kept small for MVP; grows over time. */
19
+ export interface ZoSandboxOptions {
20
+ /** Control-plane API base URL. Defaults to `ZO_API_URL` or the dev parity host. */
21
+ readonly apiBaseUrl?: string;
22
+ }
23
+
24
+ /**
25
+ * The Zo built-in sandbox for an eve agent. Returns a `SandboxDefinition` to
26
+ * default-export from `agent/sandbox.ts`.
27
+ *
28
+ * The backend is a factory so it reads `ZO_API_URL` lazily at first use, not at
29
+ * module load (eve's recommended form for env-dependent options).
30
+ */
31
+ export function zoSandbox(options: ZoSandboxOptions = {}): SandboxDefinition {
32
+ return defineSandbox({
33
+ backend: () =>
34
+ zoBackend({
35
+ apiBaseUrl:
36
+ options.apiBaseUrl ?? process.env.ZO_API_URL ?? DEFAULT_API_URL,
37
+ }),
38
+ });
39
+ }
@@ -0,0 +1,44 @@
1
+ export const DEFAULT_IMAGE_OUTPUT_DIR = "generated";
2
+
3
+ const MEDIA_TYPE_EXTENSIONS: Readonly<Record<string, string>> = {
4
+ "image/jpeg": "jpg",
5
+ "image/png": "png",
6
+ "image/webp": "webp",
7
+ };
8
+
9
+ export function extensionForMediaType(mediaType: string): string {
10
+ return MEDIA_TYPE_EXTENSIONS[mediaType] ?? "bin";
11
+ }
12
+
13
+ export function slugForPrompt(prompt: string): string {
14
+ const slug = prompt
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "")
18
+ .slice(0, 48);
19
+
20
+ return slug.length > 0 ? slug : "image";
21
+ }
22
+
23
+ export interface ImagePathInput {
24
+ readonly id: string;
25
+ readonly mediaType: string;
26
+ readonly outputDir?: string | null | undefined;
27
+ readonly prompt: string;
28
+ }
29
+
30
+ function normalizedOutputDir(outputDir: string | null | undefined): string {
31
+ const trimmed = outputDir?.trim();
32
+ const dir = trimmed && trimmed.length > 0 ? trimmed : DEFAULT_IMAGE_OUTPUT_DIR;
33
+ const withoutTrailingSlash = dir.replace(/\/+$/g, "");
34
+
35
+ return withoutTrailingSlash.length > 0
36
+ ? withoutTrailingSlash
37
+ : DEFAULT_IMAGE_OUTPUT_DIR;
38
+ }
39
+
40
+ export function imageOutputPath(input: ImagePathInput): string {
41
+ return `${normalizedOutputDir(input.outputDir)}/${slugForPrompt(input.prompt)}-${
42
+ input.id
43
+ }.${extensionForMediaType(input.mediaType)}`;
44
+ }