@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.
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/attachments.js +52 -0
- package/dist/gateway-fetch.js +67 -0
- package/dist/index.js +4169 -0
- package/dist/initiator-auth.js +49 -0
- package/dist/platform/agent-sandbox/index.js +691 -0
- package/dist/platform/cloud-tools/image.js +498 -0
- package/dist/platform/cloud-tools/index.js +507 -0
- package/dist/platform/cloud-tools/web-search.js +87 -0
- package/dist/platform/runtime-ai/gateway.js +87 -0
- package/dist/platform/runtime-ai/index.js +91 -0
- package/dist/platform/runtime-ai/register.js +88 -0
- package/dist/platform/runtime-ai/session-fetch.js +54 -0
- package/dist/platform/runtime-auth/index.js +141 -0
- package/dist/state-files.js +287 -0
- package/dist/state-sandbox.js +383 -0
- package/dist/state.js +29 -0
- package/dist/steer-inbox.js +84 -0
- package/dist/steer.js +83 -0
- package/package.json +143 -0
- package/platform/agent-sandbox/api-client.ts +196 -0
- package/platform/agent-sandbox/index.ts +27 -0
- package/platform/agent-sandbox/pure.ts +83 -0
- package/platform/agent-sandbox/sftp.ts +141 -0
- package/platform/agent-sandbox/ssh-connection.ts +165 -0
- package/platform/agent-sandbox/ssh-exec.ts +98 -0
- package/platform/agent-sandbox/ssh-session.ts +487 -0
- package/platform/agent-sandbox/zo-backend.ts +88 -0
- package/platform/agent-sandbox/zo-sandbox.ts +39 -0
- package/platform/cloud-tools/image-path.ts +44 -0
- package/platform/cloud-tools/image.ts +225 -0
- package/platform/cloud-tools/index.ts +22 -0
- package/platform/cloud-tools/state-files.ts +368 -0
- package/platform/cloud-tools/tool-meta.ts +32 -0
- package/platform/cloud-tools/web-search.ts +15 -0
- package/platform/runtime-ai/gateway.ts +76 -0
- package/platform/runtime-ai/index.ts +20 -0
- package/platform/runtime-ai/register.ts +26 -0
- package/platform/runtime-ai/session-fetch.ts +124 -0
- package/platform/runtime-auth/index.ts +331 -0
- package/src/async-tasks.ts +273 -0
- package/src/attachments.ts +109 -0
- package/src/backgroundable.ts +88 -0
- package/src/bounded-output.ts +159 -0
- package/src/dir-conventions.ts +238 -0
- package/src/extract/cache.ts +40 -0
- package/src/extract/docx.ts +18 -0
- package/src/extract/pdf.ts +54 -0
- package/src/extract/sheet.ts +56 -0
- package/src/file-kind.ts +258 -0
- package/src/file-view.ts +80 -0
- package/src/gateway-fetch.ts +115 -0
- package/src/glob-match.ts +13 -0
- package/src/hooks.ts +213 -0
- package/src/index.ts +419 -0
- package/src/initiator-auth.ts +81 -0
- package/src/instructions.ts +224 -0
- package/src/list-files.ts +41 -0
- package/src/mock-model.ts +572 -0
- package/src/park-delivery.ts +247 -0
- package/src/path-locks.ts +52 -0
- package/src/read-file-content.ts +142 -0
- package/src/read-text.ts +40 -0
- package/src/redeliver.ts +155 -0
- package/src/run.ts +159 -0
- package/src/sandbox-io.ts +414 -0
- package/src/state-files.ts +460 -0
- package/src/state-sandbox.ts +710 -0
- package/src/state.ts +96 -0
- package/src/steer-inbox.ts +105 -0
- package/src/steer-tool.ts +83 -0
- package/src/steer.ts +146 -0
- package/src/task.ts +320 -0
- package/src/tools/bash.ts +143 -0
- package/src/tools/edit.ts +58 -0
- package/src/tools/glob.ts +56 -0
- package/src/tools/grep.ts +188 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/tasks.ts +241 -0
- package/src/tools/webfetch.ts +368 -0
- package/src/tools/write.ts +42 -0
- package/src/walk.ts +112 -0
- package/src/watch-output.ts +104 -0
- package/src/web-fetch.ts +324 -0
- package/src/web-page.ts +179 -0
- package/src/workspace-io.ts +225 -0
- 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
|
+
}
|