@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
package/package.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zocomputer/agent-sdk",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A standard library for eve agents: workspace file tools (read/edit/write/glob/grep), a backgroundable bash with bounded output, async task orchestration, and rich-filetype reads (PDF, DOCX, spreadsheets) — one createStdlib call.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zocomputer/agent-sdk.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/zocomputer/agent-sdk#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"eve",
|
|
14
|
+
"agent",
|
|
15
|
+
"ai",
|
|
16
|
+
"tools",
|
|
17
|
+
"llm"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/index.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./attachments": {
|
|
25
|
+
"types": "./src/attachments.ts",
|
|
26
|
+
"import": "./dist/attachments.js"
|
|
27
|
+
},
|
|
28
|
+
"./gateway-fetch": {
|
|
29
|
+
"types": "./src/gateway-fetch.ts",
|
|
30
|
+
"import": "./dist/gateway-fetch.js"
|
|
31
|
+
},
|
|
32
|
+
"./initiator-auth": {
|
|
33
|
+
"types": "./src/initiator-auth.ts",
|
|
34
|
+
"import": "./dist/initiator-auth.js"
|
|
35
|
+
},
|
|
36
|
+
"./state": {
|
|
37
|
+
"types": "./src/state.ts",
|
|
38
|
+
"import": "./dist/state.js"
|
|
39
|
+
},
|
|
40
|
+
"./state-files": {
|
|
41
|
+
"types": "./src/state-files.ts",
|
|
42
|
+
"import": "./dist/state-files.js"
|
|
43
|
+
},
|
|
44
|
+
"./state-sandbox": {
|
|
45
|
+
"types": "./src/state-sandbox.ts",
|
|
46
|
+
"import": "./dist/state-sandbox.js"
|
|
47
|
+
},
|
|
48
|
+
"./steer": {
|
|
49
|
+
"types": "./src/steer.ts",
|
|
50
|
+
"import": "./dist/steer.js"
|
|
51
|
+
},
|
|
52
|
+
"./steer-inbox": {
|
|
53
|
+
"types": "./src/steer-inbox.ts",
|
|
54
|
+
"import": "./dist/steer-inbox.js"
|
|
55
|
+
},
|
|
56
|
+
"./runtime-auth": {
|
|
57
|
+
"types": "./platform/runtime-auth/index.ts",
|
|
58
|
+
"import": "./dist/platform/runtime-auth/index.js"
|
|
59
|
+
},
|
|
60
|
+
"./sandbox": {
|
|
61
|
+
"types": "./platform/agent-sandbox/index.ts",
|
|
62
|
+
"import": "./dist/platform/agent-sandbox/index.js"
|
|
63
|
+
},
|
|
64
|
+
"./ai": {
|
|
65
|
+
"types": "./platform/runtime-ai/index.ts",
|
|
66
|
+
"import": "./dist/platform/runtime-ai/index.js"
|
|
67
|
+
},
|
|
68
|
+
"./ai/gateway": {
|
|
69
|
+
"types": "./platform/runtime-ai/gateway.ts",
|
|
70
|
+
"import": "./dist/platform/runtime-ai/gateway.js"
|
|
71
|
+
},
|
|
72
|
+
"./ai/register": {
|
|
73
|
+
"types": "./platform/runtime-ai/register.ts",
|
|
74
|
+
"import": "./dist/platform/runtime-ai/register.js"
|
|
75
|
+
},
|
|
76
|
+
"./ai/session-fetch": {
|
|
77
|
+
"types": "./platform/runtime-ai/session-fetch.ts",
|
|
78
|
+
"import": "./dist/platform/runtime-ai/session-fetch.js"
|
|
79
|
+
},
|
|
80
|
+
"./cloud-tools": {
|
|
81
|
+
"types": "./platform/cloud-tools/index.ts",
|
|
82
|
+
"import": "./dist/platform/cloud-tools/index.js"
|
|
83
|
+
},
|
|
84
|
+
"./cloud-tools/image": {
|
|
85
|
+
"types": "./platform/cloud-tools/image.ts",
|
|
86
|
+
"import": "./dist/platform/cloud-tools/image.js"
|
|
87
|
+
},
|
|
88
|
+
"./cloud-tools/web-search": {
|
|
89
|
+
"types": "./platform/cloud-tools/web-search.ts",
|
|
90
|
+
"import": "./dist/platform/cloud-tools/web-search.js"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"files": [
|
|
94
|
+
"src",
|
|
95
|
+
"!src/**/*.test.ts",
|
|
96
|
+
"!src/**/*.test-helpers.ts",
|
|
97
|
+
"!src/extract/fixtures",
|
|
98
|
+
"!src/**/AGENTS.md",
|
|
99
|
+
"!src/**/CLAUDE.md",
|
|
100
|
+
"platform",
|
|
101
|
+
"!platform/**/*.test.ts",
|
|
102
|
+
"dist"
|
|
103
|
+
],
|
|
104
|
+
"publishConfig": {
|
|
105
|
+
"access": "public",
|
|
106
|
+
"provenance": false
|
|
107
|
+
},
|
|
108
|
+
"scripts": {
|
|
109
|
+
"typecheck": "tsc --noEmit"
|
|
110
|
+
},
|
|
111
|
+
"dependencies": {
|
|
112
|
+
"@ai-sdk/provider-utils": "5.0.1",
|
|
113
|
+
"clawpdf": "0.3.0",
|
|
114
|
+
"defuddle": "^0.19.1",
|
|
115
|
+
"htmlparser2": "^12.0.0",
|
|
116
|
+
"ignore": "^7.0.5",
|
|
117
|
+
"image-size": "2.0.2",
|
|
118
|
+
"jose": "^6.2.3",
|
|
119
|
+
"linkedom": "^0.18.12",
|
|
120
|
+
"mammoth": "1.12.0",
|
|
121
|
+
"ssh2": "^1.17.0",
|
|
122
|
+
"turndown": "^7.2.4",
|
|
123
|
+
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
|
124
|
+
},
|
|
125
|
+
"peerDependencies": {
|
|
126
|
+
"ai": "^7.0.2",
|
|
127
|
+
"eve": "^0.16.0",
|
|
128
|
+
"zod": "^4.4.3"
|
|
129
|
+
},
|
|
130
|
+
"devDependencies": {
|
|
131
|
+
"@ai-sdk/provider": "^4.0.1",
|
|
132
|
+
"@types/bun": "^1.3.14",
|
|
133
|
+
"@types/node": "^20",
|
|
134
|
+
"@types/ssh2": "^1.15.5",
|
|
135
|
+
"@types/turndown": "^5.0.6",
|
|
136
|
+
"ai": "^7.0.2",
|
|
137
|
+
"eslint": "^9",
|
|
138
|
+
"eve": "^0.16.0",
|
|
139
|
+
"fast-check": "^4.8.0",
|
|
140
|
+
"typescript": "^6.0.3",
|
|
141
|
+
"zod": "^4.4.3"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AGENT_TOKEN_ENV,
|
|
3
|
+
AGENT_TOKEN_HEADER,
|
|
4
|
+
EVE_SESSION_HEADER,
|
|
5
|
+
} from "../runtime-auth/index.ts";
|
|
6
|
+
import type { SshSandboxAccess } from "./ssh-session";
|
|
7
|
+
|
|
8
|
+
// The runtime's thin HTTP client to the control plane (apps/api) STATE BROKER.
|
|
9
|
+
// The runtime has no Daytona key; it asks the broker to resolve its session's
|
|
10
|
+
// `scratch` sandbox state (POST /state/handles) and hand back scoped SSH access,
|
|
11
|
+
// then connects over SSH. The org-wide Daytona key lives only in apps/api.
|
|
12
|
+
//
|
|
13
|
+
// This is design-doc D10: the default per-session runtime sandbox is now the
|
|
14
|
+
// `scratch` external-state declaration, resolved through the SAME broker every
|
|
15
|
+
// other state uses — there is no bespoke POST /sandbox/session route anymore.
|
|
16
|
+
// The broker zero-configs an unbound `scratch` declaration onto the sandbox
|
|
17
|
+
// engine, so we send `suggestedDefaults.engine = "sandbox-daytona"` to keep it
|
|
18
|
+
// off the broker's R2 default; a user rebinding `scratch` (e.g. to a durable
|
|
19
|
+
// `user` partition) takes precedence over these hints. See
|
|
20
|
+
// plans/dcosson/external-state-sandbox.md.
|
|
21
|
+
//
|
|
22
|
+
// Auth: the runtime carries its **agent token** — a short-lived, apps/api-minted
|
|
23
|
+
// JWT identifying the actor, injected into the runtime env (`ZO_AGENT_TOKEN`) the
|
|
24
|
+
// same way the AI gateway key is. It rides a dedicated header (off `Authorization`,
|
|
25
|
+
// so it never shadows a WorkOS session), and the eve session rides its own header
|
|
26
|
+
// so apps/api carries it onto the resolved auth context. The header names + env
|
|
27
|
+
// var come from `@zocomputer/runtime-auth` (the one source of truth). apps/api is
|
|
28
|
+
// the sole verifier. POST /state/handles is AGENT-TOKEN-ONLY: it rejects a human
|
|
29
|
+
// session with 403 `unsupported_actor`, so every caller must present an agent token.
|
|
30
|
+
// See plans/rc2/runtime-auth-context.md and plans/dcosson/external-state-sandbox.md.
|
|
31
|
+
|
|
32
|
+
// Re-export the contract constants this client uses, so a consumer importing the
|
|
33
|
+
// client surface gets them without reaching into @zocomputer/runtime-auth directly.
|
|
34
|
+
export { AGENT_TOKEN_ENV, AGENT_TOKEN_HEADER, EVE_SESSION_HEADER };
|
|
35
|
+
|
|
36
|
+
/** The seed `scratch` declaration the default runtime sandbox resolves through. */
|
|
37
|
+
export const SCRATCH_DECLARATION = "scratch";
|
|
38
|
+
|
|
39
|
+
/** The broker path the runtime resolves its sandbox state handle through. */
|
|
40
|
+
const STATE_HANDLE_PATH = "/state/handles";
|
|
41
|
+
|
|
42
|
+
/** The slice of `fetch` we use — narrower than `typeof fetch` so a test can stub it. */
|
|
43
|
+
export type FetchLike = (
|
|
44
|
+
input: string,
|
|
45
|
+
init?: RequestInit,
|
|
46
|
+
) => Promise<Response>;
|
|
47
|
+
|
|
48
|
+
/** The scoped SSH access the broker's sandbox state handle carries — host/user + expiry. */
|
|
49
|
+
export type SandboxSessionResponse = SshSandboxAccess;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A typed broker failure. `code` is the broker's error-taxonomy string (e.g.
|
|
53
|
+
* `unsupported_actor`, `eve_session_required`, `provisioning_failed`); `status`
|
|
54
|
+
* the HTTP status. Callers can branch on `code` rather than parse the message.
|
|
55
|
+
*/
|
|
56
|
+
export class SandboxBrokerError extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
readonly code: string | null;
|
|
59
|
+
|
|
60
|
+
constructor(message: string, options: { status: number; code?: string | null }) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "SandboxBrokerError";
|
|
63
|
+
this.status = options.status;
|
|
64
|
+
this.code = options.code ?? null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse-then-narrow the scoped SSH access, rather than asserting it inward: the
|
|
70
|
+
* parsed value is fed straight into the SSH connect path (host/user), so a
|
|
71
|
+
* shape-divergent value (a renamed field, a null) must fail here at the boundary,
|
|
72
|
+
* not flow in as a bad host/username. Returns `null` on mismatch.
|
|
73
|
+
*/
|
|
74
|
+
export function parseSandboxAccess(value: unknown): SshSandboxAccess | null {
|
|
75
|
+
if (typeof value !== "object" || value === null) return null;
|
|
76
|
+
const v = value as Record<string, unknown>;
|
|
77
|
+
const { sandboxId, sshHost, sshUser, expiresAt } = v;
|
|
78
|
+
if (
|
|
79
|
+
typeof sandboxId === "string" &&
|
|
80
|
+
typeof sshHost === "string" &&
|
|
81
|
+
typeof sshUser === "string" &&
|
|
82
|
+
typeof expiresAt === "string"
|
|
83
|
+
) {
|
|
84
|
+
return { sandboxId, sshHost, sshUser, expiresAt };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Extract scoped SSH access from a broker state-handle response. The sandbox
|
|
91
|
+
* engine's handle nests access under `sandbox`; a non-sandbox handle (e.g. an
|
|
92
|
+
* R2 `files` handle, which the broker returns if `scratch` ever resolved onto
|
|
93
|
+
* the R2 default engine) has no sandbox access and fails here at the boundary,
|
|
94
|
+
* rather than flowing in as a missing host. Returns `null` on mismatch.
|
|
95
|
+
*/
|
|
96
|
+
export function parseSandboxHandleAccess(value: unknown): SshSandboxAccess | null {
|
|
97
|
+
if (typeof value !== "object" || value === null) return null;
|
|
98
|
+
const v = value as Record<string, unknown>;
|
|
99
|
+
if (v.engine !== "sandbox-daytona") return null;
|
|
100
|
+
return parseSandboxAccess(v.sandbox);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface RequestSandboxInput {
|
|
104
|
+
/** Base URL of the control-plane API (e.g. http://api.zo.localhost:4000). */
|
|
105
|
+
readonly apiBaseUrl: string;
|
|
106
|
+
/**
|
|
107
|
+
* Stable per-eve-session key. Sent as the `x-zo-eve-session` header; the broker
|
|
108
|
+
* keys the session-partitioned `scratch` instance off it (+ the caller's org),
|
|
109
|
+
* so a reply reattaches the same sandbox.
|
|
110
|
+
*/
|
|
111
|
+
readonly eveSessionKey: string;
|
|
112
|
+
/**
|
|
113
|
+
* The agent token to authenticate with. Injectable for tests; defaults to
|
|
114
|
+
* `process.env[AGENT_TOKEN_ENV]`. When absent the call is sent without the
|
|
115
|
+
* header, and the broker rejects it with 403 `unsupported_actor` (the route is
|
|
116
|
+
* agent-token-only) — a clear failure rather than a silent fallthrough.
|
|
117
|
+
*/
|
|
118
|
+
readonly agentToken?: string;
|
|
119
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
120
|
+
readonly fetch?: FetchLike;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve this session's `scratch` sandbox through the state broker and return
|
|
125
|
+
* scoped SSH access. The broker recovers the subject from the agent token +
|
|
126
|
+
* eve-session header, resolves (or zero-configs) the `scratch` binding onto the
|
|
127
|
+
* sandbox engine, and mints a short-lived SSH token for the session's sandbox —
|
|
128
|
+
* creating it, reattaching a live one, or reviving a stopped one as needed.
|
|
129
|
+
*
|
|
130
|
+
* Throws `SandboxBrokerError` on any non-2xx, carrying the broker's error code
|
|
131
|
+
* (`unsupported_actor`, `eve_session_required`, `provisioning_failed`, …) and
|
|
132
|
+
* HTTP status.
|
|
133
|
+
*/
|
|
134
|
+
export async function requestScratchSandboxAccess(
|
|
135
|
+
input: RequestSandboxInput,
|
|
136
|
+
): Promise<SshSandboxAccess> {
|
|
137
|
+
const doFetch = input.fetch ?? fetch;
|
|
138
|
+
// Treat a blank/whitespace token as absent (don't send the header) — apps/api
|
|
139
|
+
// trims and ignores a blank agent-token header. Send the trimmed token, matching
|
|
140
|
+
// the exact bytes apps/api verifies.
|
|
141
|
+
const agentToken = (input.agentToken ?? process.env[AGENT_TOKEN_ENV])?.trim() || undefined;
|
|
142
|
+
const eveSessionKey = input.eveSessionKey.trim() || undefined;
|
|
143
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
144
|
+
if (agentToken) headers[AGENT_TOKEN_HEADER] = agentToken;
|
|
145
|
+
if (eveSessionKey) headers[EVE_SESSION_HEADER] = eveSessionKey;
|
|
146
|
+
// Header-only eve session — no body `eveSessionKey`, so we never trip the
|
|
147
|
+
// route's header/body mismatch guard (the header IS the trusted session key).
|
|
148
|
+
const res = await doFetch(`${input.apiBaseUrl}${STATE_HANDLE_PATH}`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers,
|
|
151
|
+
body: JSON.stringify({
|
|
152
|
+
declarationName: SCRATCH_DECLARATION,
|
|
153
|
+
interface: "exec",
|
|
154
|
+
access: "rw",
|
|
155
|
+
suggestedDefaults: { engine: "sandbox-daytona", partition: "session" },
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
const json: unknown = await res.json().catch(() => null);
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const { code, message } = parseBrokerError(json);
|
|
161
|
+
throw new SandboxBrokerError(describeBrokerError(res.status, code, message), {
|
|
162
|
+
status: res.status,
|
|
163
|
+
code,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const access = parseSandboxHandleAccess(json);
|
|
167
|
+
if (access === null) {
|
|
168
|
+
throw new SandboxBrokerError(
|
|
169
|
+
"sandbox broker returned a non-sandbox or malformed state handle",
|
|
170
|
+
{ status: res.status, code: "malformed_handle" },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return access;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read the broker's `{ error, message }` failure envelope; both may be absent. */
|
|
177
|
+
function parseBrokerError(value: unknown): { code: string | null; message: string | null } {
|
|
178
|
+
if (typeof value !== "object" || value === null) return { code: null, message: null };
|
|
179
|
+
const v = value as Record<string, unknown>;
|
|
180
|
+
return {
|
|
181
|
+
code: typeof v.error === "string" ? v.error : null,
|
|
182
|
+
message: typeof v.message === "string" ? v.message : null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Map the broker's error taxonomy to an actionable message for the two flip-novel codes. */
|
|
187
|
+
function describeBrokerError(status: number, code: string | null, message: string | null): string {
|
|
188
|
+
if (code === "unsupported_actor") {
|
|
189
|
+
return "sandbox broker rejected the caller: POST /state/handles requires an agent token, not a human session (unsupported_actor). Ensure ZO_AGENT_TOKEN is set.";
|
|
190
|
+
}
|
|
191
|
+
if (code === "eve_session_required") {
|
|
192
|
+
return "sandbox broker requires an eve session: send the x-zo-eve-session header (eve_session_required).";
|
|
193
|
+
}
|
|
194
|
+
const detail = [code, message].filter((s): s is string => s !== null && s.length > 0).join(" — ");
|
|
195
|
+
return `sandbox provisioning failed: ${status}${detail ? ` ${detail}` : ""}`.trim();
|
|
196
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @zocomputer/agent-sandbox — the Zo built-in sandbox for eve agents.
|
|
2
|
+
//
|
|
3
|
+
// Agents author against `zoSandbox()` and never name the provider; the runtime
|
|
4
|
+
// never holds the Daytona key — it asks the Zo control plane (apps/api) to
|
|
5
|
+
// provision its sandbox and connects over SSH with a scoped token. `zoBackend()`
|
|
6
|
+
// is exported for callers that want the raw eve `SandboxBackend` directly.
|
|
7
|
+
|
|
8
|
+
export { zoSandbox, type ZoSandboxOptions } from "./zo-sandbox";
|
|
9
|
+
export { zoBackend, type ZoBackendOptions } from "./zo-backend";
|
|
10
|
+
|
|
11
|
+
// The connect-a-sandbox-by-eve-session-key primitives `zoBackend` composes
|
|
12
|
+
// internally, exported for a caller that needs a standalone `SandboxSession`
|
|
13
|
+
// OUTSIDE eve's own ALS-scoped tool/hook context — e.g. an eve channel HTTP
|
|
14
|
+
// route, which gets no `ctx.getSandbox()` (see the Builder's `agent/channels/
|
|
15
|
+
// flush.ts`, plans/sachin/code-storage-phase3-implementation.md).
|
|
16
|
+
export {
|
|
17
|
+
requestScratchSandboxAccess,
|
|
18
|
+
SandboxBrokerError,
|
|
19
|
+
type FetchLike,
|
|
20
|
+
type RequestSandboxInput,
|
|
21
|
+
type SandboxSessionResponse,
|
|
22
|
+
} from "./api-client";
|
|
23
|
+
export {
|
|
24
|
+
sshSandboxSession,
|
|
25
|
+
type SshSandboxAccess,
|
|
26
|
+
type SshSession,
|
|
27
|
+
} from "./ssh-session";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// Pure, dependency-free helpers shared by the backend and session layers.
|
|
5
|
+
// Kept here (no SSH/eve runtime objects) so they're unit-testable in isolation —
|
|
6
|
+
// see `pure.test.ts`.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Anchor a sandbox path to the work dir: relative paths resolve under
|
|
10
|
+
* `workDir`, absolute paths pass through unchanged.
|
|
11
|
+
*
|
|
12
|
+
* A relative path that escapes `workDir` via `..` (e.g. `../../etc/passwd`) is
|
|
13
|
+
* rejected — `path.posix.join` would otherwise normalize it to a location
|
|
14
|
+
* outside the workspace, which the file methods would then read/write.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveSandboxPath(workDir: string, p: string): string {
|
|
17
|
+
if (path.posix.isAbsolute(p)) return p;
|
|
18
|
+
const resolved = path.posix.join(workDir, p);
|
|
19
|
+
// Stay within workDir: either the dir itself or a path beneath it.
|
|
20
|
+
const prefix = workDir.endsWith("/") ? workDir : `${workDir}/`;
|
|
21
|
+
if (resolved !== workDir && !resolved.startsWith(prefix)) {
|
|
22
|
+
throw new Error(`sandbox path escapes the work dir: ${p}`);
|
|
23
|
+
}
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POSIX single-quote a string for safe interpolation into a shell command —
|
|
29
|
+
* wraps in `'…'` and escapes embedded single quotes as `'\''`. Used to quote a
|
|
30
|
+
* path (e.g. a `cd` target) so shell metacharacters in it are treated literally,
|
|
31
|
+
* not interpreted. (The model-supplied command itself is intentionally shell.)
|
|
32
|
+
*/
|
|
33
|
+
export function shellSingleQuote(s: string): string {
|
|
34
|
+
return `'${s.replaceAll("'", "'\\''")}'`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The reconnect metadata we persist on the eve session. `daytonaSandboxId` is
|
|
39
|
+
* the provider sandbox id; eve hands it back as `existingMetadata` on a reply so
|
|
40
|
+
* the control plane reattaches the same sandbox.
|
|
41
|
+
*/
|
|
42
|
+
export interface DaytonaSessionMetadata {
|
|
43
|
+
readonly daytonaSandboxId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Narrow persisted metadata to a non-empty `daytonaSandboxId`, else `null`. */
|
|
47
|
+
export function readSandboxId(
|
|
48
|
+
metadata: Record<string, unknown> | undefined,
|
|
49
|
+
): string | null {
|
|
50
|
+
const id = metadata?.daytonaSandboxId;
|
|
51
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a caller-supplied encoding to a Node `BufferEncoding`, or throw a
|
|
56
|
+
* clear error. `undefined`/`utf-8`/`utf8` → `"utf8"` (eve's default); anything
|
|
57
|
+
* else is validated with `Buffer.isEncoding` (a type guard, so no cast) and
|
|
58
|
+
* rejected if unknown — rather than letting an arbitrary string reach
|
|
59
|
+
* `Buffer.from(…, badEncoding)` as an unchecked cast / opaque TypeError.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveEncoding(encoding?: string): BufferEncoding {
|
|
62
|
+
if (encoding === undefined || /^utf-?8$/i.test(encoding)) return "utf8";
|
|
63
|
+
if (Buffer.isEncoding(encoding)) return encoding;
|
|
64
|
+
throw new Error(`zo sandbox: unsupported text encoding ${JSON.stringify(encoding)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Decode bytes as text. utf-8 uses fatal mode (invalid bytes throw, not replace). */
|
|
68
|
+
export function decodeText(bytes: Uint8Array, encoding?: string): string {
|
|
69
|
+
const enc = resolveEncoding(encoding);
|
|
70
|
+
if (enc === "utf8") return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
71
|
+
return Buffer.from(bytes).toString(enc);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Encode text to bytes for the given encoding (utf-8 default). */
|
|
75
|
+
export function encodeText(text: string, encoding?: string): Uint8Array {
|
|
76
|
+
const enc = resolveEncoding(encoding);
|
|
77
|
+
if (enc === "utf8") return new TextEncoder().encode(text);
|
|
78
|
+
return new Uint8Array(Buffer.from(text, enc));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Line-range slicing is NOT implemented here — readTextFile uses the AI-SDK's
|
|
82
|
+
// extractLines (the exact impl eve uses), so we don't own that edge-case-laden
|
|
83
|
+
// logic. See ssh-session.ts.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { Client, SFTPWrapper } from "ssh2";
|
|
3
|
+
import { shellSingleQuote } from "./pure";
|
|
4
|
+
import { awaitCommand } from "./ssh-exec";
|
|
5
|
+
|
|
6
|
+
// File/process operations over an established ssh2 `Client`, backing the eve
|
|
7
|
+
// `SandboxSession` file methods + `spawn`. Byte transfer rides SFTP (binary-safe,
|
|
8
|
+
// no shell-escaping of content); directory creation and removal ride `exec`
|
|
9
|
+
// (SFTP has no recursive mkdir/rm). The session wraps these with the AI-SDK
|
|
10
|
+
// text/stream/encoding contract; this module stays bytes-and-paths.
|
|
11
|
+
|
|
12
|
+
/** SFTP status code for "no such file" (per the SFTP protocol). */
|
|
13
|
+
const SFTP_NO_SUCH_FILE = 2;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Did an ssh2 SFTP error mean "the file doesn't exist"? (→ read methods return
|
|
17
|
+
* null). ssh2 reliably sets `err.code` to the SFTP status code on the error, so
|
|
18
|
+
* we match the code exactly rather than sniffing the message — a message-regex
|
|
19
|
+
* would risk treating an unrelated failure as "absent".
|
|
20
|
+
*/
|
|
21
|
+
function isNoSuchFile(error: unknown): boolean {
|
|
22
|
+
return (
|
|
23
|
+
typeof error === "object" &&
|
|
24
|
+
error !== null &&
|
|
25
|
+
(error as { code?: unknown }).code === SFTP_NO_SUCH_FILE
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One SFTP channel per connection, opened lazily and reused across operations —
|
|
30
|
+
// opening a fresh channel per read/write (and never closing it) leaks channels
|
|
31
|
+
// until the connection hits sshd's MaxSessions. Keyed by Client so it's scoped
|
|
32
|
+
// to the connection's life and GC'd with it; evicted on close/error so the next
|
|
33
|
+
// op reopens (e.g. after the connection manager reconnects with a new Client).
|
|
34
|
+
const sftpByClient = new WeakMap<Client, Promise<SFTPWrapper>>();
|
|
35
|
+
|
|
36
|
+
function getSftp(client: Client): Promise<SFTPWrapper> {
|
|
37
|
+
const existing = sftpByClient.get(client);
|
|
38
|
+
if (existing !== undefined) return existing;
|
|
39
|
+
const opening = new Promise<SFTPWrapper>((resolve, reject) => {
|
|
40
|
+
client.sftp((err, sftp) => {
|
|
41
|
+
if (err) { reject(err); return; }
|
|
42
|
+
// Drop the cache entry if this channel dies, so we reopen next time.
|
|
43
|
+
const evict = (): void => {
|
|
44
|
+
if (sftpByClient.get(client) === opening) sftpByClient.delete(client);
|
|
45
|
+
};
|
|
46
|
+
sftp.on("close", evict).on("error", evict);
|
|
47
|
+
resolve(sftp);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
// Don't cache a failed open.
|
|
51
|
+
opening.catch(() => {
|
|
52
|
+
if (sftpByClient.get(client) === opening) sftpByClient.delete(client);
|
|
53
|
+
});
|
|
54
|
+
sftpByClient.set(client, opening);
|
|
55
|
+
return opening;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run a command to completion over exec, returning its exit code + stderr. Exit
|
|
60
|
+
* reconciliation (signal→nonzero, so a killed mkdir/rm isn't a false 0, and
|
|
61
|
+
* channel-error→reject so we never hang) is shared via awaitCommand.
|
|
62
|
+
*/
|
|
63
|
+
function exec(
|
|
64
|
+
client: Client,
|
|
65
|
+
command: string,
|
|
66
|
+
): Promise<{ exitCode: number; stderr: string }> {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
client.exec(command, (err, stream) => {
|
|
69
|
+
if (err) { reject(err); return; }
|
|
70
|
+
let stderr = "";
|
|
71
|
+
stream.on("data", () => {}).stderr.on("data", (d: Buffer) => (stderr += d.toString()));
|
|
72
|
+
awaitCommand(stream).then(
|
|
73
|
+
({ exitCode }) => resolve({ exitCode, stderr }),
|
|
74
|
+
reject,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Ensure a file's parent directory exists (SFTP can't mkdir -p). */
|
|
81
|
+
async function ensureParentDir(client: Client, filePath: string): Promise<void> {
|
|
82
|
+
const dir = path.posix.dirname(filePath);
|
|
83
|
+
if (dir === "" || dir === "." || dir === "/") return;
|
|
84
|
+
const r = await exec(client, `mkdir -p ${shellSingleQuote(dir)}`);
|
|
85
|
+
if (r.exitCode !== 0) {
|
|
86
|
+
throw new Error(`sandbox: mkdir -p ${dir} failed (exit ${r.exitCode}): ${r.stderr.trim()}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read a file's bytes, or `null` if it doesn't exist. */
|
|
91
|
+
export async function sftpReadBytes(
|
|
92
|
+
client: Client,
|
|
93
|
+
remotePath: string,
|
|
94
|
+
): Promise<Uint8Array | null> {
|
|
95
|
+
const sftp = await getSftp(client);
|
|
96
|
+
return await new Promise<Uint8Array | null>((resolve, reject) => {
|
|
97
|
+
sftp.readFile(remotePath, (err, buf) => {
|
|
98
|
+
if (err) {
|
|
99
|
+
if (isNoSuchFile(err)) resolve(null);
|
|
100
|
+
else reject(err);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
resolve(new Uint8Array(buf));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Write bytes to a file, creating parent dirs and overwriting any existing file. */
|
|
109
|
+
export async function sftpWriteBytes(
|
|
110
|
+
client: Client,
|
|
111
|
+
remotePath: string,
|
|
112
|
+
bytes: Uint8Array,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
await ensureParentDir(client, remotePath);
|
|
115
|
+
const sftp = await getSftp(client);
|
|
116
|
+
await new Promise<void>((resolve, reject) => {
|
|
117
|
+
sftp.writeFile(remotePath, Buffer.from(bytes), (err) =>
|
|
118
|
+
err ? reject(err) : resolve(),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove a path. `recursive` allows non-empty dirs; `force` (→ `rm -f`) makes a
|
|
125
|
+
* MISSING path a success. A non-zero exit is always an error: with `-f`, `rm`
|
|
126
|
+
* itself already exits 0 for a missing path, so a remaining non-zero means a
|
|
127
|
+
* real failure (permission denied, non-empty dir without `recursive`, …) we must
|
|
128
|
+
* surface rather than swallow.
|
|
129
|
+
*/
|
|
130
|
+
export async function removePath(
|
|
131
|
+
client: Client,
|
|
132
|
+
remotePath: string,
|
|
133
|
+
opts: { recursive?: boolean | undefined; force?: boolean | undefined } = {},
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const flags = `${opts.recursive ? "r" : ""}${opts.force ? "f" : ""}`;
|
|
136
|
+
const rm = flags === "" ? "rm" : `rm -${flags}`;
|
|
137
|
+
const r = await exec(client, `${rm} ${shellSingleQuote(remotePath)}`);
|
|
138
|
+
if (r.exitCode !== 0) {
|
|
139
|
+
throw new Error(`sandbox: ${rm} ${remotePath} failed (exit ${r.exitCode}): ${r.stderr.trim()}`);
|
|
140
|
+
}
|
|
141
|
+
}
|