@dynamicagents/plugins 0.13.0 → 0.15.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/README.md +14 -14
- package/dist/claude-code/cards.d.ts +4 -0
- package/dist/claude-code/cards.js +22 -0
- package/dist/claude-code/config.d.ts +10 -83
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +32 -50
- package/dist/claude-code/credentials.js +115 -74
- package/dist/claude-code/daemon/index.d.ts +24 -0
- package/dist/claude-code/daemon/index.js +203 -0
- package/dist/claude-code/egress.d.ts +35 -117
- package/dist/claude-code/egress.js +80 -325
- package/dist/claude-code/events.d.ts +14 -17
- package/dist/claude-code/events.js +19 -15
- package/dist/claude-code/harness-wire.d.ts +45 -0
- package/dist/claude-code/harness-wire.js +47 -0
- package/dist/claude-code/index.d.ts +19 -29
- package/dist/claude-code/index.js +17 -25
- package/dist/claude-code/language-model.d.ts +19 -0
- package/dist/claude-code/language-model.js +71 -0
- package/dist/claude-code/model.d.ts +77 -70
- package/dist/claude-code/model.js +328 -304
- package/dist/claude-code/placeholder.d.ts +12 -0
- package/dist/claude-code/placeholder.js +12 -0
- package/dist/claude-code/spec.d.ts +4 -4
- package/dist/claude-code/spec.js +4 -4
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/computer/client.d.ts +88 -0
- package/dist/computer/client.js +6 -0
- package/dist/computer/context.d.ts +7 -7
- package/dist/computer/context.js +14 -18
- package/dist/computer/gate.d.ts +4 -23
- package/dist/computer/gate.js +1 -37
- package/dist/computer/index.d.ts +44 -43
- package/dist/computer/index.js +29 -50
- package/dist/computer/paths.d.ts +5 -27
- package/dist/computer/paths.js +7 -30
- package/dist/computer/proxy.d.ts +17 -31
- package/dist/computer/proxy.js +9 -44
- package/dist/computer/render.d.ts +3 -40
- package/dist/computer/render.js +3 -36
- package/dist/computer/sandbox.d.ts +55 -0
- package/dist/computer/sandbox.js +236 -0
- package/dist/computer/shell.d.ts +36 -0
- package/dist/computer/shell.js +41 -0
- package/dist/computer/tools-exec.js +3 -9
- package/dist/computer/tools-file.d.ts +1 -1
- package/dist/computer/tools-file.js +1 -1
- package/dist/{repo → repo-github}/checkout.d.ts +3 -4
- package/dist/{repo → repo-github}/checkout.js +3 -4
- package/dist/{repo → repo-github}/context.d.ts +14 -32
- package/dist/{repo → repo-github}/context.js +46 -81
- package/dist/{repo → repo-github}/index.d.ts +30 -13
- package/dist/{repo → repo-github}/index.js +25 -6
- package/dist/repo-github/mcp.d.ts +95 -0
- package/dist/repo-github/mcp.js +107 -0
- package/dist/repo-github/sandbox-egress.d.ts +68 -0
- package/dist/repo-github/sandbox-egress.js +272 -0
- package/dist/repo-github/sandbox-git.d.ts +30 -0
- package/dist/repo-github/sandbox-git.js +113 -0
- package/dist/{repo → repo-github}/tools-checks.js +43 -4
- package/dist/{repo → repo-github}/tools-clone.js +2 -21
- package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
- package/dist/{repo → repo-github}/tools-pr.js +85 -20
- package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
- package/dist/{repo → repo-github}/tools-review.js +62 -176
- package/dist/{repo → repo-github}/url.d.ts +11 -0
- package/dist/{repo → repo-github}/url.js +12 -1
- package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
- package/dist/{workspace → sandbox}/advisory.js +2 -29
- package/dist/sandbox/agent-exec.d.ts +31 -0
- package/dist/sandbox/agent-exec.js +62 -0
- package/dist/sandbox/ca-trust.d.ts +9 -0
- package/dist/sandbox/ca-trust.js +21 -0
- package/dist/sandbox/constants.d.ts +22 -0
- package/dist/sandbox/constants.js +22 -0
- package/dist/sandbox/daemon/composite.d.ts +9 -0
- package/dist/sandbox/daemon/composite.js +35 -0
- package/dist/sandbox/daemon/index.d.ts +47 -0
- package/dist/sandbox/daemon/index.js +116 -0
- package/dist/sandbox/egress.d.ts +81 -0
- package/dist/sandbox/egress.js +109 -0
- package/dist/sandbox/exec.d.ts +57 -0
- package/dist/sandbox/exec.js +52 -0
- package/dist/sandbox/format.d.ts +2 -0
- package/dist/sandbox/format.js +7 -0
- package/dist/sandbox/header.d.ts +50 -0
- package/dist/sandbox/header.js +94 -0
- package/dist/sandbox/index.d.ts +17 -0
- package/dist/sandbox/index.js +17 -0
- package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +10 -1
- package/dist/{workspace/install.js → sandbox/install-plan.js} +22 -4
- package/dist/sandbox/name.d.ts +27 -0
- package/dist/sandbox/name.js +32 -0
- package/dist/sandbox/object.d.ts +244 -0
- package/dist/sandbox/object.js +1139 -0
- package/dist/scratch/index.d.ts +1 -9
- package/dist/scratch/index.js +2 -18
- package/package.json +26 -22
- package/dist/claude-code/github.d.ts +0 -39
- package/dist/claude-code/github.js +0 -82
- package/dist/claude-code/run.d.ts +0 -474
- package/dist/claude-code/run.js +0 -901
- package/dist/claude-code/session.d.ts +0 -131
- package/dist/claude-code/session.js +0 -285
- package/dist/workspace/ca-trust.d.ts +0 -95
- package/dist/workspace/ca-trust.js +0 -312
- package/dist/workspace/container-deps.d.ts +0 -54
- package/dist/workspace/container-deps.js +0 -224
- package/dist/workspace/container-fault.d.ts +0 -38
- package/dist/workspace/container-fault.js +0 -73
- package/dist/workspace/exec.d.ts +0 -94
- package/dist/workspace/exec.js +0 -153
- package/dist/workspace/format.d.ts +0 -36
- package/dist/workspace/format.js +0 -57
- package/dist/workspace/git-host.d.ts +0 -79
- package/dist/workspace/git-host.js +0 -226
- package/dist/workspace/git-identity.d.ts +0 -57
- package/dist/workspace/git-identity.js +0 -147
- package/dist/workspace/index.d.ts +0 -49
- package/dist/workspace/index.js +0 -58
- package/dist/workspace/install-job.d.ts +0 -121
- package/dist/workspace/install-job.js +0 -866
- package/dist/workspace/object.d.ts +0 -502
- package/dist/workspace/object.js +0 -1427
- package/dist/workspace/open.d.ts +0 -95
- package/dist/workspace/open.js +0 -63
- package/dist/workspace/read.d.ts +0 -16
- package/dist/workspace/read.js +0 -22
- package/dist/workspace/shell.d.ts +0 -45
- package/dist/workspace/shell.js +0 -80
- package/dist/workspace/sync.d.ts +0 -111
- package/dist/workspace/sync.js +0 -124
- package/dist/workspace/wake.d.ts +0 -19
- package/dist/workspace/wake.js +0 -10
- /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
- /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { EgressRoute } from "agents/harness/container";
|
|
2
|
+
/**
|
|
3
|
+
* The container's way out, on the Worker side.
|
|
4
|
+
*
|
|
5
|
+
* A plugin that needs a host from inside the container declares it as a
|
|
6
|
+
* {@link SandboxEgressRule}: the hosts to intercept, the placeholders its
|
|
7
|
+
* processes see, and the handler that swaps a placeholder for the real
|
|
8
|
+
* credential. A consumer lists the rules a sandbox class carries once, and
|
|
9
|
+
* hands that list both to the class's config and to the entrypoint that calls
|
|
10
|
+
* {@link sandboxEgress}. This module names no host and holds no credential.
|
|
11
|
+
*
|
|
12
|
+
* An intercepted request reaches the entrypoint, a stateless Worker invocation
|
|
13
|
+
* outside the container. The sandbox object is on the path only when a handler
|
|
14
|
+
* calls it ({@link EgressCall.sandbox}).
|
|
15
|
+
*/
|
|
16
|
+
export interface SandboxEgressRule {
|
|
17
|
+
/** Names the rule where a clash or a refusal is reported. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/**
|
|
20
|
+
* Hosts intercepted over plain HTTP, each to its real `https:` upstream: a
|
|
21
|
+
* placeholder host the container is pointed at, so nothing in it terminates
|
|
22
|
+
* TLS.
|
|
23
|
+
*/
|
|
24
|
+
readonly http?: Readonly<Record<string, string>>;
|
|
25
|
+
/**
|
|
26
|
+
* Real hosts intercepted over HTTPS, each to its `https:` upstream, for a
|
|
27
|
+
* client that cannot be pointed at a placeholder, or whose URLs other tools
|
|
28
|
+
* read back, as git's remotes are. The sandbox trusts the runtime's
|
|
29
|
+
* interception CA in every container that has one.
|
|
30
|
+
*/
|
|
31
|
+
readonly https?: Readonly<Record<string, string>>;
|
|
32
|
+
/**
|
|
33
|
+
* What a process needs to reach those hosts — a placeholder credential, a
|
|
34
|
+
* base URL. Every command `exec` runs and every CLI turn gets it, over its
|
|
35
|
+
* own environment. Never a secret: the container can read it.
|
|
36
|
+
*/
|
|
37
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
38
|
+
/** Serve one intercepted request: swap the placeholder for the real credential, then {@link forward}. */
|
|
39
|
+
handle(call: EgressCall): Promise<Response>;
|
|
40
|
+
}
|
|
41
|
+
/** One request the container made, for the rule serving its host. */
|
|
42
|
+
export interface EgressCall {
|
|
43
|
+
readonly request: Request;
|
|
44
|
+
/** The rule's upstream for the request's host, with the request's path and query. */
|
|
45
|
+
readonly target: URL;
|
|
46
|
+
/** The Worker's `env`, where a credential is read at the moment it is used. */
|
|
47
|
+
readonly env: Readonly<Record<string, unknown>>;
|
|
48
|
+
/**
|
|
49
|
+
* The sandbox whose container made the request, by the name it was reached
|
|
50
|
+
* by — waking it if it is not in memory. Throws for a sandbox reached without
|
|
51
|
+
* a name.
|
|
52
|
+
*/
|
|
53
|
+
sandbox(): DurableObjectStub;
|
|
54
|
+
}
|
|
55
|
+
/** What one sandbox registers for its rules. */
|
|
56
|
+
export interface EgressPlan {
|
|
57
|
+
/** Intercepted over HTTP by the harness, at every start. */
|
|
58
|
+
readonly http: readonly EgressRoute[];
|
|
59
|
+
/** Intercepted over HTTPS by the sandbox, once per container. */
|
|
60
|
+
readonly https: readonly EgressRoute[];
|
|
61
|
+
/** Every rule's `env`, merged. */
|
|
62
|
+
readonly env: Readonly<Record<string, string>>;
|
|
63
|
+
}
|
|
64
|
+
/** Headers that carry a credential, which a handler removes from what the container sent. */
|
|
65
|
+
export declare const CREDENTIAL_HEADERS: readonly string[];
|
|
66
|
+
/**
|
|
67
|
+
* `rules` as the sandbox `sandbox` (`<binding>/<name>`) registers them.
|
|
68
|
+
*
|
|
69
|
+
* Throws for a host two rules declare, an environment key two rules set, or an
|
|
70
|
+
* upstream that is not https: each would otherwise surface as a request that
|
|
71
|
+
* reaches the wrong handler, or none.
|
|
72
|
+
*/
|
|
73
|
+
export declare function egressPlan(rules: readonly SandboxEgressRule[], sandbox?: string): EgressPlan;
|
|
74
|
+
/**
|
|
75
|
+
* Serve one request the container made: `routes` are what the sandbox
|
|
76
|
+
* registered for this container, which the harness hands the entrypoint as its
|
|
77
|
+
* props, and `rules` the same list the sandbox's config holds.
|
|
78
|
+
*/
|
|
79
|
+
export declare function sandboxEgress(request: Request, routes: readonly EgressRoute[], env: Readonly<Record<string, unknown>>, rules: readonly SandboxEgressRule[]): Promise<Response>;
|
|
80
|
+
/** `call.request` at its target, with `rewrite` applied to its headers; never following a redirect. */
|
|
81
|
+
export declare function forward(call: EgressCall, rewrite: (headers: Headers) => void, body?: BodyInit): Promise<Response>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On each route, `<binding>/<name>` of the sandbox it was registered by. A
|
|
3
|
+
* route is the only thing the harness hands the entrypoint, so it is the only
|
|
4
|
+
* way the entrypoint can know which sandbox's container is calling.
|
|
5
|
+
*/
|
|
6
|
+
const ROUTE_SANDBOX = "x-da-sandbox";
|
|
7
|
+
/** Headers that carry a credential, which a handler removes from what the container sent. */
|
|
8
|
+
export const CREDENTIAL_HEADERS = [
|
|
9
|
+
"authorization",
|
|
10
|
+
"x-api-key",
|
|
11
|
+
"proxy-authorization"
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* `rules` as the sandbox `sandbox` (`<binding>/<name>`) registers them.
|
|
15
|
+
*
|
|
16
|
+
* Throws for a host two rules declare, an environment key two rules set, or an
|
|
17
|
+
* upstream that is not https: each would otherwise surface as a request that
|
|
18
|
+
* reaches the wrong handler, or none.
|
|
19
|
+
*/
|
|
20
|
+
export function egressPlan(rules, sandbox) {
|
|
21
|
+
const hosts = new Map();
|
|
22
|
+
const keys = new Map();
|
|
23
|
+
const http = [];
|
|
24
|
+
const https = [];
|
|
25
|
+
const env = {};
|
|
26
|
+
const route = (rule, host, upstream) => {
|
|
27
|
+
const owner = hosts.get(host);
|
|
28
|
+
if (owner !== undefined)
|
|
29
|
+
throw new Error(`egress: ${host} is declared by both ${owner} and ${rule.name}`);
|
|
30
|
+
hosts.set(host, rule.name);
|
|
31
|
+
if (!upstream.startsWith("https://"))
|
|
32
|
+
throw new Error(`egress: ${rule.name} sends ${host} to ${upstream}, which is not https`);
|
|
33
|
+
return {
|
|
34
|
+
host,
|
|
35
|
+
upstream,
|
|
36
|
+
headers: sandbox === undefined ? {} : { [ROUTE_SANDBOX]: sandbox },
|
|
37
|
+
strip: []
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
for (const rule of rules) {
|
|
41
|
+
for (const [host, upstream] of Object.entries(rule.http ?? {}))
|
|
42
|
+
http.push(route(rule, host, upstream));
|
|
43
|
+
for (const [host, upstream] of Object.entries(rule.https ?? {}))
|
|
44
|
+
https.push(route(rule, host, upstream));
|
|
45
|
+
for (const [key, value] of Object.entries(rule.env ?? {})) {
|
|
46
|
+
const owner = keys.get(key);
|
|
47
|
+
if (owner !== undefined)
|
|
48
|
+
throw new Error(`egress: ${key} is set by both ${owner} and ${rule.name}`);
|
|
49
|
+
keys.set(key, rule.name);
|
|
50
|
+
env[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { http, https, env };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Serve one request the container made: `routes` are what the sandbox
|
|
57
|
+
* registered for this container, which the harness hands the entrypoint as its
|
|
58
|
+
* props, and `rules` the same list the sandbox's config holds.
|
|
59
|
+
*/
|
|
60
|
+
export async function sandboxEgress(request, routes, env, rules) {
|
|
61
|
+
const url = new URL(request.url);
|
|
62
|
+
const route = routes.find((each) => each.host === url.hostname);
|
|
63
|
+
if (!route) {
|
|
64
|
+
return new Response(`No egress route for ${url.hostname}`, { status: 403 });
|
|
65
|
+
}
|
|
66
|
+
for (const rule of rules) {
|
|
67
|
+
const upstream = upstreamOf(rule.http, url.hostname) ??
|
|
68
|
+
upstreamOf(rule.https, url.hostname);
|
|
69
|
+
if (upstream === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
const named = route.headers[ROUTE_SANDBOX];
|
|
72
|
+
return rule.handle({
|
|
73
|
+
request,
|
|
74
|
+
target: new URL(`${upstream.replace(/\/+$/, "")}${url.pathname}${url.search}`),
|
|
75
|
+
env,
|
|
76
|
+
sandbox: () => sandboxStub(env, named)
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return new Response(`No egress rule serves ${url.hostname}`, {
|
|
80
|
+
status: 403
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** `call.request` at its target, with `rewrite` applied to its headers; never following a redirect. */
|
|
84
|
+
export async function forward(call, rewrite, body) {
|
|
85
|
+
const headers = new Headers(call.request.headers);
|
|
86
|
+
headers.delete("host");
|
|
87
|
+
rewrite(headers);
|
|
88
|
+
const hasBody = call.request.method !== "GET" && call.request.method !== "HEAD";
|
|
89
|
+
return fetch(call.target, {
|
|
90
|
+
method: call.request.method,
|
|
91
|
+
headers,
|
|
92
|
+
...(hasBody ? { body: body ?? call.request.body } : {}),
|
|
93
|
+
redirect: "manual"
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function upstreamOf(hosts, host) {
|
|
97
|
+
return hosts && Object.hasOwn(hosts, host) ? hosts[host] : undefined;
|
|
98
|
+
}
|
|
99
|
+
function sandboxStub(env, named) {
|
|
100
|
+
const slash = named?.indexOf("/") ?? -1;
|
|
101
|
+
const namespace = named !== undefined && slash > 0 ? env[named.slice(0, slash)] : undefined;
|
|
102
|
+
if (named === undefined ||
|
|
103
|
+
!namespace ||
|
|
104
|
+
typeof namespace.getByName !== "function") {
|
|
105
|
+
throw new Error(`egress: this route names no sandbox the Worker can reach (${JSON.stringify(named ?? null)}); ` +
|
|
106
|
+
"a sandbox is reached by name, and its config's `egress.self` is its binding");
|
|
107
|
+
}
|
|
108
|
+
return namespace.getByName(named.slice(slash + 1));
|
|
109
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shell in a sandbox, for a plugin that needs one but does not own the
|
|
3
|
+
* container: `/repo-github`'s git, `/scratch`, a host's own scripts. It answers
|
|
4
|
+
* the contract those plugins take — `success`, `stdout`, `stderr` and
|
|
5
|
+
* `exitCode`.
|
|
6
|
+
*
|
|
7
|
+
* Every command runs as the agent, with every egress rule's `env`, and under
|
|
8
|
+
* `timeout` at its limit: the sandbox's own `exec` can only stop waiting, and a
|
|
9
|
+
* command it stopped waiting for would go on running.
|
|
10
|
+
*
|
|
11
|
+
* It merges no host environment of its own: this is a raw shell for another
|
|
12
|
+
* plugin's commands, and a host environment underneath would let through what
|
|
13
|
+
* git honours — `GIT_PROXY_COMMAND`, `GIT_CONFIG_GLOBAL`, `http_proxy`. What a
|
|
14
|
+
* host merges into a plugin's commands it composes where the merge is visible.
|
|
15
|
+
*/
|
|
16
|
+
/** The sandbox RPC this uses. */
|
|
17
|
+
export interface SandboxExecHost {
|
|
18
|
+
exec(argv: string[], options?: {
|
|
19
|
+
cwd?: string;
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
exitCode: number;
|
|
24
|
+
stdout: string;
|
|
25
|
+
stderr: string;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export interface SandboxExecConfig {
|
|
29
|
+
/** Addressed by name only: the sandbox's lifecycle needs it. */
|
|
30
|
+
binding: DurableObjectNamespace<SandboxExecHost & Rpc.DurableObjectBranded>;
|
|
31
|
+
/** The sandbox a parent's own commands run in. A sub-agent's comes from its `runtime()`. */
|
|
32
|
+
sandboxName: () => string;
|
|
33
|
+
/** Default `/workspace`. */
|
|
34
|
+
cwd?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The shell a command string runs in. Default `bash`: a model writing shell
|
|
37
|
+
* writes bash, and `/bin/sh` is dash on Debian, which fails `${PIPESTATUS[0]}`
|
|
38
|
+
* and the rest of that family after the command they wrapped has run.
|
|
39
|
+
*/
|
|
40
|
+
shell?: string;
|
|
41
|
+
/** Per command. Default {@link DEFAULT_EXEC_TIMEOUT_MS}. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/** A command that has not finished in this long is a hung command. */
|
|
45
|
+
export declare const DEFAULT_EXEC_TIMEOUT_MS: number;
|
|
46
|
+
export declare function sandboxExec(config: SandboxExecConfig): (command: string, options?: {
|
|
47
|
+
cwd?: string;
|
|
48
|
+
env?: Record<string, string | undefined>;
|
|
49
|
+
timeout?: number;
|
|
50
|
+
/** A sub-agent's `runtime()`, forwarded opaquely by the plugin calling this. */
|
|
51
|
+
runtime?: unknown;
|
|
52
|
+
}) => Promise<{
|
|
53
|
+
success: boolean;
|
|
54
|
+
stdout: string;
|
|
55
|
+
stderr: string;
|
|
56
|
+
exitCode: number;
|
|
57
|
+
}>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { humanMs } from "./format.js";
|
|
2
|
+
import { SANDBOX_ROOT } from "./header.js";
|
|
3
|
+
import { sandboxNameFromRuntime } from "./name.js";
|
|
4
|
+
/** A command that has not finished in this long is a hung command. */
|
|
5
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
|
+
/** After `timeout`'s TERM, how long before its KILL. */
|
|
7
|
+
const KILL_AFTER_S = 5;
|
|
8
|
+
/**
|
|
9
|
+
* `timeout`'s exits for a command it ended. Read as the limit only once the
|
|
10
|
+
* limit has passed: any SIGKILL is a 137, an out-of-memory kill included.
|
|
11
|
+
*/
|
|
12
|
+
const TIMED_OUT = new Set([124, 137]);
|
|
13
|
+
export function sandboxExec(config) {
|
|
14
|
+
return async (command, options) => {
|
|
15
|
+
const name = sandboxNameFromRuntime(options?.runtime) ?? config.sandboxName();
|
|
16
|
+
const limitMs = options?.timeout ?? config.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
17
|
+
const env = options?.env
|
|
18
|
+
? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== undefined))
|
|
19
|
+
: undefined;
|
|
20
|
+
const startedAt = Date.now();
|
|
21
|
+
const run = await config.binding
|
|
22
|
+
.getByName(name)
|
|
23
|
+
.exec([
|
|
24
|
+
"timeout",
|
|
25
|
+
`--kill-after=${KILL_AFTER_S}`,
|
|
26
|
+
String(Math.ceil(limitMs / 1000)),
|
|
27
|
+
config.shell ?? "bash",
|
|
28
|
+
"-c",
|
|
29
|
+
command
|
|
30
|
+
], {
|
|
31
|
+
cwd: options?.cwd ?? config.cwd ?? SANDBOX_ROOT,
|
|
32
|
+
...(env ? { env } : {}),
|
|
33
|
+
timeoutMs: limitMs + (KILL_AFTER_S + 5) * 1000
|
|
34
|
+
});
|
|
35
|
+
const killed = TIMED_OUT.has(run.exitCode) && Date.now() - startedAt >= limitMs;
|
|
36
|
+
return {
|
|
37
|
+
success: run.exitCode === 0,
|
|
38
|
+
stdout: run.stdout,
|
|
39
|
+
// Appended rather than substituted: a command killed at its limit may
|
|
40
|
+
// have written plenty first, and `stdout` is a channel callers parse.
|
|
41
|
+
stderr: killed
|
|
42
|
+
? [
|
|
43
|
+
run.stderr,
|
|
44
|
+
`the command was killed at its time limit of ${humanMs(limitMs)}, so anything it had not yet written is gone`
|
|
45
|
+
]
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join("\n")
|
|
48
|
+
: run.stderr,
|
|
49
|
+
exitCode: run.exitCode
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run header: how a host tells the daemon how to launch one run.
|
|
3
|
+
*
|
|
4
|
+
* `ContainerHarness` takes its adapter options once, for every session it holds,
|
|
5
|
+
* and its adapters run every turn in one fixed working directory. A sandbox runs
|
|
6
|
+
* a planner, a writer and a reviser in the same object, each in its own
|
|
7
|
+
* checkout and under its own permission mode, so neither is enough. The
|
|
8
|
+
* harness passes a prompt through untouched, so the run's own settings travel
|
|
9
|
+
* as the prompt's first line, and the daemon takes that line off before the CLI
|
|
10
|
+
* reads its stdin.
|
|
11
|
+
*
|
|
12
|
+
* Parsed on both sides from this one module, so the spelling cannot drift.
|
|
13
|
+
*/
|
|
14
|
+
/** The first characters of a prompt that carries a header. */
|
|
15
|
+
export declare const RUN_HEADER_PREFIX = "@@da ";
|
|
16
|
+
/** Where every run's working directory must sit. */
|
|
17
|
+
export declare const SANDBOX_ROOT = "/workspace";
|
|
18
|
+
export interface RunHeader {
|
|
19
|
+
/** The checkout the run works in. Fixed for a session's life: the CLI files a transcript by directory. */
|
|
20
|
+
cwd: string;
|
|
21
|
+
/** `plan` runs read-only and answers to {@link schema}. */
|
|
22
|
+
mode?: "plan" | "build";
|
|
23
|
+
/** A schema the daemon was built with, by name. Not the schema itself: a prompt is stored. */
|
|
24
|
+
schema?: string;
|
|
25
|
+
/** The CLI's effort level, passed through. */
|
|
26
|
+
effort?: string;
|
|
27
|
+
/** Continue the session's conversation as a new branch of it. */
|
|
28
|
+
fork?: boolean;
|
|
29
|
+
/** The CLI's model, when the run needs one other than the session's. */
|
|
30
|
+
model?: string;
|
|
31
|
+
}
|
|
32
|
+
/** A header the daemon cannot honour, refused rather than run with defaults. */
|
|
33
|
+
export declare class RunHeaderError extends Error {
|
|
34
|
+
readonly name = "RunHeaderError";
|
|
35
|
+
}
|
|
36
|
+
/** `prompt` with `header` as its first line. */
|
|
37
|
+
export declare function withRunHeader(header: RunHeader, prompt: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* The header and the prompt beneath it. A prompt with no header line is
|
|
40
|
+
* returned whole with `header: undefined`: control prompts and a session's own
|
|
41
|
+
* follow-ups carry none.
|
|
42
|
+
*
|
|
43
|
+
* @throws {RunHeaderError} the first line claims to be a header and is not one.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readRunHeader(text: string): {
|
|
46
|
+
header: RunHeader | undefined;
|
|
47
|
+
prompt: string;
|
|
48
|
+
};
|
|
49
|
+
/** Whether `path` is `root` or inside it, with no `..` escaping it. */
|
|
50
|
+
export declare function isUnder(path: string, root: string): boolean;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run header: how a host tells the daemon how to launch one run.
|
|
3
|
+
*
|
|
4
|
+
* `ContainerHarness` takes its adapter options once, for every session it holds,
|
|
5
|
+
* and its adapters run every turn in one fixed working directory. A sandbox runs
|
|
6
|
+
* a planner, a writer and a reviser in the same object, each in its own
|
|
7
|
+
* checkout and under its own permission mode, so neither is enough. The
|
|
8
|
+
* harness passes a prompt through untouched, so the run's own settings travel
|
|
9
|
+
* as the prompt's first line, and the daemon takes that line off before the CLI
|
|
10
|
+
* reads its stdin.
|
|
11
|
+
*
|
|
12
|
+
* Parsed on both sides from this one module, so the spelling cannot drift.
|
|
13
|
+
*/
|
|
14
|
+
/** The first characters of a prompt that carries a header. */
|
|
15
|
+
export const RUN_HEADER_PREFIX = "@@da ";
|
|
16
|
+
/** Where every run's working directory must sit. */
|
|
17
|
+
export const SANDBOX_ROOT = "/workspace";
|
|
18
|
+
/** A header the daemon cannot honour, refused rather than run with defaults. */
|
|
19
|
+
export class RunHeaderError extends Error {
|
|
20
|
+
name = "RunHeaderError";
|
|
21
|
+
}
|
|
22
|
+
/** `prompt` with `header` as its first line. */
|
|
23
|
+
export function withRunHeader(header, prompt) {
|
|
24
|
+
assertRunHeader(header);
|
|
25
|
+
return `${RUN_HEADER_PREFIX}${JSON.stringify(header)}\n${prompt}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The header and the prompt beneath it. A prompt with no header line is
|
|
29
|
+
* returned whole with `header: undefined`: control prompts and a session's own
|
|
30
|
+
* follow-ups carry none.
|
|
31
|
+
*
|
|
32
|
+
* @throws {RunHeaderError} the first line claims to be a header and is not one.
|
|
33
|
+
*/
|
|
34
|
+
export function readRunHeader(text) {
|
|
35
|
+
if (!text.startsWith(RUN_HEADER_PREFIX))
|
|
36
|
+
return { header: undefined, prompt: text };
|
|
37
|
+
const end = text.indexOf("\n");
|
|
38
|
+
const line = end === -1 ? text : text.slice(0, end);
|
|
39
|
+
const prompt = end === -1 ? "" : text.slice(end + 1);
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(line.slice(RUN_HEADER_PREFIX.length));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new RunHeaderError(`the run header is not JSON: ${line.slice(0, 120)}`);
|
|
46
|
+
}
|
|
47
|
+
assertRunHeader(value);
|
|
48
|
+
return { header: value, prompt };
|
|
49
|
+
}
|
|
50
|
+
function assertRunHeader(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
52
|
+
throw new RunHeaderError("the run header must be an object");
|
|
53
|
+
}
|
|
54
|
+
const header = value;
|
|
55
|
+
const cwd = header.cwd;
|
|
56
|
+
if (typeof cwd !== "string" || !isUnder(cwd, SANDBOX_ROOT)) {
|
|
57
|
+
throw new RunHeaderError(`the run header's cwd must be an absolute path under ${SANDBOX_ROOT}, got ${JSON.stringify(cwd)}`);
|
|
58
|
+
}
|
|
59
|
+
if (header.mode !== undefined &&
|
|
60
|
+
header.mode !== "plan" &&
|
|
61
|
+
header.mode !== "build") {
|
|
62
|
+
throw new RunHeaderError(`unknown run mode ${JSON.stringify(header.mode)}`);
|
|
63
|
+
}
|
|
64
|
+
for (const key of ["schema", "effort", "model"]) {
|
|
65
|
+
if (header[key] !== undefined && typeof header[key] !== "string") {
|
|
66
|
+
throw new RunHeaderError(`the run header's ${key} must be a string`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (header.fork !== undefined && typeof header.fork !== "boolean") {
|
|
70
|
+
throw new RunHeaderError("the run header's fork must be a boolean");
|
|
71
|
+
}
|
|
72
|
+
if (header.mode === "plan" && header.schema === undefined) {
|
|
73
|
+
throw new RunHeaderError("a plan run names the schema its answer follows");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Whether `path` is `root` or inside it, with no `..` escaping it. */
|
|
77
|
+
export function isUnder(path, root) {
|
|
78
|
+
if (!path.startsWith("/"))
|
|
79
|
+
return false;
|
|
80
|
+
const parts = [];
|
|
81
|
+
for (const part of path.split("/")) {
|
|
82
|
+
if (part === "" || part === ".")
|
|
83
|
+
continue;
|
|
84
|
+
if (part === "..") {
|
|
85
|
+
if (parts.length === 0)
|
|
86
|
+
return false;
|
|
87
|
+
parts.pop();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
parts.push(part);
|
|
91
|
+
}
|
|
92
|
+
const normal = `/${parts.join("/")}`;
|
|
93
|
+
return normal === root || normal.startsWith(`${root}/`);
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dynamicagents/plugins/sandbox` — a container whose disk is the checkout,
|
|
3
|
+
* driven from a Durable Object through `agents`' `ContainerHarness`.
|
|
4
|
+
*
|
|
5
|
+
* The README has the model. The daemon an image runs is the separate,
|
|
6
|
+
* Node-only `@dynamicagents/plugins/sandbox/daemon`.
|
|
7
|
+
*/
|
|
8
|
+
export { SandboxObjectBase, type CheckoutRecord, type ExecResult, type PolledMessage, type PollResult, type SandboxObjectConfig, type SnapshotSeed } from "./object.js";
|
|
9
|
+
export { CONTROL_SESSION, CONTROL_WAKE, EGRESS_READY, RUN_DIR, SANDBOX_CONTRACT } from "./constants.js";
|
|
10
|
+
export { readRunHeader, RUN_HEADER_PREFIX, RunHeaderError, SANDBOX_ROOT, withRunHeader, type RunHeader } from "./header.js";
|
|
11
|
+
export { AGENT_GID, AGENT_HOME, AGENT_UID, agentEnv, asAgent } from "./agent-exec.js";
|
|
12
|
+
export { sandboxName, sandboxNameFromRuntime, SANDBOX_RUNTIME_KEY } from "./name.js";
|
|
13
|
+
export { DEFAULT_EXEC_TIMEOUT_MS, sandboxExec, type SandboxExecConfig, type SandboxExecHost } from "./exec.js";
|
|
14
|
+
export { CREDENTIAL_HEADERS, egressPlan, forward, sandboxEgress, type EgressCall, type EgressPlan, type SandboxEgressRule } from "./egress.js";
|
|
15
|
+
export { deriveAdvisories, renderAdvisory, sessionAdvisory, shapeOf, type AdvisoryAudience, type AdvisoryShape, type SandboxAdvisory } from "./advisory.js";
|
|
16
|
+
export { DEFAULT_INSTALL_PLAN, resolveInstallCommand, type InstallPlan, type InstallRule, type InstallState } from "./install-plan.js";
|
|
17
|
+
export { DirectoryBackupGateway } from "@cloudflare/sandbox";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dynamicagents/plugins/sandbox` — a container whose disk is the checkout,
|
|
3
|
+
* driven from a Durable Object through `agents`' `ContainerHarness`.
|
|
4
|
+
*
|
|
5
|
+
* The README has the model. The daemon an image runs is the separate,
|
|
6
|
+
* Node-only `@dynamicagents/plugins/sandbox/daemon`.
|
|
7
|
+
*/
|
|
8
|
+
export { SandboxObjectBase } from "./object.js";
|
|
9
|
+
export { CONTROL_SESSION, CONTROL_WAKE, EGRESS_READY, RUN_DIR, SANDBOX_CONTRACT } from "./constants.js";
|
|
10
|
+
export { readRunHeader, RUN_HEADER_PREFIX, RunHeaderError, SANDBOX_ROOT, withRunHeader } from "./header.js";
|
|
11
|
+
export { AGENT_GID, AGENT_HOME, AGENT_UID, agentEnv, asAgent } from "./agent-exec.js";
|
|
12
|
+
export { sandboxName, sandboxNameFromRuntime, SANDBOX_RUNTIME_KEY } from "./name.js";
|
|
13
|
+
export { DEFAULT_EXEC_TIMEOUT_MS, sandboxExec } from "./exec.js";
|
|
14
|
+
export { CREDENTIAL_HEADERS, egressPlan, forward, sandboxEgress } from "./egress.js";
|
|
15
|
+
export { deriveAdvisories, renderAdvisory, sessionAdvisory, shapeOf } from "./advisory.js";
|
|
16
|
+
export { DEFAULT_INSTALL_PLAN, resolveInstallCommand } from "./install-plan.js";
|
|
17
|
+
export { DirectoryBackupGateway } from "@cloudflare/sandbox";
|
|
@@ -184,6 +184,15 @@ export declare function resolveInstallCommand(fs: InstallProbe, dir: string, pla
|
|
|
184
184
|
* A matching fingerprint means "the same install would produce the same tree".
|
|
185
185
|
* It does **not** mean the tree is there: it is on the container's disk, and
|
|
186
186
|
* goes with the container. The caller also needs a record that this container
|
|
187
|
-
* finished it — see `./
|
|
187
|
+
* finished it — see `startInstall` in `./object.ts`.
|
|
188
188
|
*/
|
|
189
189
|
export declare function installFingerprint(fs: InstallProbe, dir: string, resolution: InstallResolution): Promise<string | null>;
|
|
190
|
+
/**
|
|
191
|
+
* The same digest for a package root an install reached without being pointed
|
|
192
|
+
* at it — a submodule a bootstrap installs, say — where there is no command to
|
|
193
|
+
* fold in, and every lockfile the plan knows stands in for the one it would pick.
|
|
194
|
+
*
|
|
195
|
+
* For a caller restoring every root's tree at once from one snapshot, which has
|
|
196
|
+
* to know none of them moved.
|
|
197
|
+
*/
|
|
198
|
+
export declare function rootFingerprint(fs: InstallProbe, dir: string, plan: InstallPlan): Promise<string | null>;
|
|
@@ -96,6 +96,10 @@ function pinnedManager(packageJson) {
|
|
|
96
96
|
return undefined;
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
/** Every lockfile the plan names, each once. */
|
|
100
|
+
function planLockfiles(plan) {
|
|
101
|
+
return [...new Set(plan.rules.flatMap((r) => [...r.lockfiles]))];
|
|
102
|
+
}
|
|
99
103
|
/**
|
|
100
104
|
* Every lockfile the plan knows of that is present in `dir`.
|
|
101
105
|
*
|
|
@@ -103,9 +107,8 @@ function pinnedManager(packageJson) {
|
|
|
103
107
|
* about which manager it drives.
|
|
104
108
|
*/
|
|
105
109
|
async function knownLockfiles(fs, dir, plan) {
|
|
106
|
-
const names = [...new Set(plan.rules.flatMap((r) => [...r.lockfiles]))];
|
|
107
110
|
const present = [];
|
|
108
|
-
for (const name of
|
|
111
|
+
for (const name of planLockfiles(plan)) {
|
|
109
112
|
if (await fs.exists(`${dir}/${name}`))
|
|
110
113
|
present.push(name);
|
|
111
114
|
}
|
|
@@ -259,7 +262,7 @@ async function firstPresent(fs, dir, names) {
|
|
|
259
262
|
* A matching fingerprint means "the same install would produce the same tree".
|
|
260
263
|
* It does **not** mean the tree is there: it is on the container's disk, and
|
|
261
264
|
* goes with the container. The caller also needs a record that this container
|
|
262
|
-
* finished it — see `./
|
|
265
|
+
* finished it — see `startInstall` in `./object.ts`.
|
|
263
266
|
*/
|
|
264
267
|
export async function installFingerprint(fs, dir, resolution) {
|
|
265
268
|
if (resolution.kind !== "run")
|
|
@@ -273,10 +276,25 @@ export async function installFingerprint(fs, dir, resolution) {
|
|
|
273
276
|
...resolution.lockfiles,
|
|
274
277
|
...INSTALL_CONFIG_FILES
|
|
275
278
|
];
|
|
279
|
+
return await digestFiles(fs, dir, names, resolution.command);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The same digest for a package root an install reached without being pointed
|
|
283
|
+
* at it — a submodule a bootstrap installs, say — where there is no command to
|
|
284
|
+
* fold in, and every lockfile the plan knows stands in for the one it would pick.
|
|
285
|
+
*
|
|
286
|
+
* For a caller restoring every root's tree at once from one snapshot, which has
|
|
287
|
+
* to know none of them moved.
|
|
288
|
+
*/
|
|
289
|
+
export async function rootFingerprint(fs, dir, plan) {
|
|
290
|
+
return await digestFiles(fs, dir, ["package.json", ...planLockfiles(plan), ...INSTALL_CONFIG_FILES], "");
|
|
291
|
+
}
|
|
292
|
+
/** `prefix`, then every file of `names` present in `dir`; null when none is. */
|
|
293
|
+
async function digestFiles(fs, dir, names, prefix) {
|
|
276
294
|
// Names as well as contents. Two lockfiles will not collide on content in
|
|
277
295
|
// practice, but a digest that cannot say *which* file it read is one that
|
|
278
296
|
// silently depends on the caller resolving them in the same order forever.
|
|
279
|
-
let input =
|
|
297
|
+
let input = prefix;
|
|
280
298
|
let found = false;
|
|
281
299
|
for (const name of names) {
|
|
282
300
|
if (!(await fs.exists(`${dir}/${name}`)))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sandbox a caller reaches: its name, and how a parent hands it to a
|
|
3
|
+
* sub-agent.
|
|
4
|
+
*
|
|
5
|
+
* One sandbox is one container is one repository, so a caller's sandboxes are
|
|
6
|
+
* keyed on the repository it works in. A module of its own, with no imports,
|
|
7
|
+
* because every path into a sandbox — a plugin's tools, a host's shell, a
|
|
8
|
+
* sub-agent's `prepare` — must spell it the same way.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The Durable Object name for one caller's sandbox for one repository.
|
|
12
|
+
*
|
|
13
|
+
* A pipe rather than a slash, so the caller half cannot forge a repository
|
|
14
|
+
* boundary by containing one. `repo` may be a sentinel rather than a
|
|
15
|
+
* repository — a scratchpad, a worktree — as long as no caller could clone
|
|
16
|
+
* something that collides with it. Undefined is the window before a task has
|
|
17
|
+
* chosen one, which resolves to a caller-level sandbox nothing works in.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sandboxName(callerKey: string, repo?: string): string;
|
|
20
|
+
/** The key a parent's `prepare` puts a sub-agent's sandbox name under, in its `runtime()`. */
|
|
21
|
+
export declare const SANDBOX_RUNTIME_KEY = "sandboxName";
|
|
22
|
+
/**
|
|
23
|
+
* A parent-resolved sandbox name, read off a sub-agent's `runtime()`.
|
|
24
|
+
* `undefined` for anything else: a parent has no runtime, and falls back to its
|
|
25
|
+
* own name.
|
|
26
|
+
*/
|
|
27
|
+
export declare function sandboxNameFromRuntime(runtime: unknown): string | undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which sandbox a caller reaches: its name, and how a parent hands it to a
|
|
3
|
+
* sub-agent.
|
|
4
|
+
*
|
|
5
|
+
* One sandbox is one container is one repository, so a caller's sandboxes are
|
|
6
|
+
* keyed on the repository it works in. A module of its own, with no imports,
|
|
7
|
+
* because every path into a sandbox — a plugin's tools, a host's shell, a
|
|
8
|
+
* sub-agent's `prepare` — must spell it the same way.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The Durable Object name for one caller's sandbox for one repository.
|
|
12
|
+
*
|
|
13
|
+
* A pipe rather than a slash, so the caller half cannot forge a repository
|
|
14
|
+
* boundary by containing one. `repo` may be a sentinel rather than a
|
|
15
|
+
* repository — a scratchpad, a worktree — as long as no caller could clone
|
|
16
|
+
* something that collides with it. Undefined is the window before a task has
|
|
17
|
+
* chosen one, which resolves to a caller-level sandbox nothing works in.
|
|
18
|
+
*/
|
|
19
|
+
export function sandboxName(callerKey, repo) {
|
|
20
|
+
return repo ? `${callerKey}|${repo}` : `${callerKey}|<unassigned>`;
|
|
21
|
+
}
|
|
22
|
+
/** The key a parent's `prepare` puts a sub-agent's sandbox name under, in its `runtime()`. */
|
|
23
|
+
export const SANDBOX_RUNTIME_KEY = "sandboxName";
|
|
24
|
+
/**
|
|
25
|
+
* A parent-resolved sandbox name, read off a sub-agent's `runtime()`.
|
|
26
|
+
* `undefined` for anything else: a parent has no runtime, and falls back to its
|
|
27
|
+
* own name.
|
|
28
|
+
*/
|
|
29
|
+
export function sandboxNameFromRuntime(runtime) {
|
|
30
|
+
const value = runtime?.[SANDBOX_RUNTIME_KEY];
|
|
31
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
32
|
+
}
|