@agentswf/sandbox 0.0.0-stage → 0.1.0-beta.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/docker/Dockerfile +45 -0
- package/docker/proxy.js +45 -0
- package/docker/relay.js +36 -0
- package/package.json +34 -4
- package/src/docker/args.ts +65 -0
- package/src/docker/index.ts +860 -0
- package/src/git.ts +152 -0
- package/src/groups.ts +81 -0
- package/src/index.ts +3 -0
- package/src/once.ts +16 -0
- package/src/pane.ts +39 -0
- package/src/resolve.ts +346 -0
- package/src/seam.ts +214 -0
- package/src/secrets.ts +67 -0
- package/src/srt/index.ts +425 -0
- package/src/srt/profile.ts +191 -0
- package/src/testing/conformance.ts +437 -0
- package/src/testing/fake.ts +134 -0
- package/src/testing/index.ts +2 -0
- package/README.md +0 -3
package/src/seam.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AbsoluteDeadline,
|
|
3
|
+
Domain,
|
|
4
|
+
Gitdir,
|
|
5
|
+
JsonObject,
|
|
6
|
+
SandboxEnvironmentKey,
|
|
7
|
+
SandboxKey,
|
|
8
|
+
SandboxReach,
|
|
9
|
+
} from "@agentswf/contract/workflow";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How long `runProcess` waits for a `SandboxedCommand`'s `reap` once its group is killed. A reap
|
|
13
|
+
* that takes longer is abandoned, so a provider's must finish well within it.
|
|
14
|
+
*/
|
|
15
|
+
export const REAP_GRACE_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
export interface SandboxProvider<E> {
|
|
18
|
+
/**
|
|
19
|
+
* Validates the raw environment settings, so untyped workflows are checked at run time. What it
|
|
20
|
+
* returns is recorded as the spec's settings: plain JSON, in the contract's shape for its key.
|
|
21
|
+
*/
|
|
22
|
+
environment(raw: unknown): E;
|
|
23
|
+
/**
|
|
24
|
+
* Holds the whole spec, or rejects naming what it cannot hold. Never opens narrower. The engine
|
|
25
|
+
* calls `admit` on one sandbox at a time, `release` once per occupant, and `close` once, after
|
|
26
|
+
* every release.
|
|
27
|
+
*/
|
|
28
|
+
open(spec: ResolvedSandbox<E>, context: SandboxContext): Promise<OpenedSandbox>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The providers an operator installed, keyed by the environment name a spec uses for each. */
|
|
32
|
+
export type SandboxProviders = {
|
|
33
|
+
readonly installed: Readonly<Partial<Record<SandboxEnvironmentKey, SandboxProvider<unknown>>>>;
|
|
34
|
+
/** The environment a spec that names none runs in; absent when nothing is installed. */
|
|
35
|
+
readonly default?: SandboxEnvironmentKey;
|
|
36
|
+
/** Why a provider this machine has could not be set up: said when a spec names it. */
|
|
37
|
+
readonly unavailable?: Readonly<Partial<Record<SandboxEnvironmentKey, string>>>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The spec with its paths resolved: absolute, real, `~` expanded, domains lowercased, and a path
|
|
42
|
+
* in both lists only in `write`. Every reach field is present, so a new one is a compile error in
|
|
43
|
+
* each provider rather than a field it silently ignores.
|
|
44
|
+
*/
|
|
45
|
+
export type ResolvedSandbox<E> = {
|
|
46
|
+
readonly [K in keyof SandboxReach]-?: NonNullable<SandboxReach[K]>;
|
|
47
|
+
} & {
|
|
48
|
+
key: SandboxKey;
|
|
49
|
+
cwd: string;
|
|
50
|
+
/**
|
|
51
|
+
* Paths inside reach that no agent reads or writes, which the provider hides: the run root,
|
|
52
|
+
* when an allowed path holds it, as a project holds `.awf/runs`.
|
|
53
|
+
*/
|
|
54
|
+
hidden: readonly string[];
|
|
55
|
+
gitdirs: readonly Gitdir[];
|
|
56
|
+
environment: E;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type { Gitdir };
|
|
60
|
+
|
|
61
|
+
export type SandboxContext = {
|
|
62
|
+
/** Every run's directory. Denied inside, and in the spec's `hidden` when an allowed path holds it. */
|
|
63
|
+
runRoot: string;
|
|
64
|
+
/** This sandbox's directory, holding `homes/`; outside the run root when the engine makes it. */
|
|
65
|
+
directory: string;
|
|
66
|
+
deadline: AbsoluteDeadline;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface OpenedSandbox {
|
|
70
|
+
/** Readies the sandbox for one agent, or rejects: a cwd outside reach, a harness the image lacks. */
|
|
71
|
+
admit(agent: AgentContext): Promise<Occupant>;
|
|
72
|
+
/** Ends everything started inside and removes what `open` made, except `homes/`. */
|
|
73
|
+
close(): Promise<void>;
|
|
74
|
+
/** What only the provider knows about how it runs this sandbox: the record's `provided`. */
|
|
75
|
+
readonly record?: JsonObject;
|
|
76
|
+
/**
|
|
77
|
+
* It hosts pane agents: each occupant's `pane()` gives one its terminal. A sandbox-level fact,
|
|
78
|
+
* so a pane in one that does not is refused before the agent's channel opens.
|
|
79
|
+
*/
|
|
80
|
+
readonly panes?: true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type AgentContext = {
|
|
84
|
+
cwd: string;
|
|
85
|
+
/** This agent's seeded home, under `directory/homes/`. */
|
|
86
|
+
home: string;
|
|
87
|
+
harness: HarnessSandboxNeeds;
|
|
88
|
+
door: AgentDoor;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export interface Occupant {
|
|
92
|
+
/**
|
|
93
|
+
* The host command that runs `root` inside, for this agent. `root.env` is the adapter's overlay;
|
|
94
|
+
* the command's `env` is the whole environment. `stdin`, `timeoutMs` and `signal` pass through.
|
|
95
|
+
*/
|
|
96
|
+
launch(root: SandboxProcess): SandboxedCommand;
|
|
97
|
+
/** Stops this agent's processes and its route to the door. Never the engine's channel. */
|
|
98
|
+
release(): Promise<void>;
|
|
99
|
+
/** The terminal a pane agent runs in; present where the sandbox has `panes`. */
|
|
100
|
+
pane?(): Promise<PaneTerminal>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Where a pane agent runs, and what is typed to start it there (story 004, "Panes"). */
|
|
104
|
+
export type PaneTerminal = {
|
|
105
|
+
herdr: PaneHerdr;
|
|
106
|
+
/**
|
|
107
|
+
* Typed into the new pane's shell. It sets the pane's whole environment, loads the agent's
|
|
108
|
+
* secret from a file it then deletes, so no token is on a command line, and `exec`s the confined
|
|
109
|
+
* shell that becomes the pane's root process.
|
|
110
|
+
*/
|
|
111
|
+
prelude: string;
|
|
112
|
+
/**
|
|
113
|
+
* What the confined shell's prompt shows, and the typed prelude cannot: the one proof that the
|
|
114
|
+
* pane's shell is the sandbox's, not the operator's, before the harness is typed into it.
|
|
115
|
+
*/
|
|
116
|
+
ready: string;
|
|
117
|
+
/** How the harness is named in the confined shell, before its arguments. */
|
|
118
|
+
harness: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The Herdr a pane opens in: the run's own, or one inside the sandbox, whose commands run through
|
|
123
|
+
* `run` so a cancelled one is reaped. Every terminal of one sandbox gives the same `key`, by which
|
|
124
|
+
* a host keeps one workspace in it, driven by the first `run` it was given.
|
|
125
|
+
*/
|
|
126
|
+
export type PaneHerdr =
|
|
127
|
+
| "run"
|
|
128
|
+
| {
|
|
129
|
+
key: string;
|
|
130
|
+
run(args: readonly string[], timeoutMs: number): SandboxedCommand;
|
|
131
|
+
/** What shows this Herdr's panes when run in a terminal on the host. */
|
|
132
|
+
watch?: readonly string[];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** The process fields the seam carries, which harness's `runProcess` accepts. */
|
|
136
|
+
export type SandboxProcess = {
|
|
137
|
+
argv: readonly string[];
|
|
138
|
+
cwd?: string;
|
|
139
|
+
env?: Readonly<Record<string, string>>;
|
|
140
|
+
stdin?: string;
|
|
141
|
+
timeoutMs: number;
|
|
142
|
+
signal?: AbortSignal;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run by `runProcess` as its own process group with exactly `env`, inheriting nothing. Plain data,
|
|
147
|
+
* own properties only, so a spread copies it whole: `reap` needs no `this`.
|
|
148
|
+
*/
|
|
149
|
+
export type SandboxedCommand = SandboxProcess & {
|
|
150
|
+
group: true;
|
|
151
|
+
/** Ends what the command left running inside, after `runProcess` has killed its group. */
|
|
152
|
+
reap?: () => Promise<void>;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** The agent's one way back to the engine. */
|
|
156
|
+
export type AgentDoor = {
|
|
157
|
+
endpoint: string;
|
|
158
|
+
/** The path the prompt names. It must run at this path inside and reach `endpoint`. */
|
|
159
|
+
launcher: string;
|
|
160
|
+
/** The launcher's contents for a box, copied to `launcher` inside it: `bundle` run by `bun` from `PATH`. */
|
|
161
|
+
boxScript: string;
|
|
162
|
+
/** The bundled `wf` command, a host file copied to the same path inside a box. */
|
|
163
|
+
bundle: string;
|
|
164
|
+
/** Host paths the host launcher reads: its interpreter and the bundle. */
|
|
165
|
+
reads: readonly string[];
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export type HarnessSandboxNeeds = {
|
|
169
|
+
/** Variables pointing the harness at its home. */
|
|
170
|
+
env: Readonly<Record<string, string>>;
|
|
171
|
+
/**
|
|
172
|
+
* Credential files copied into the home, and written back when a turn refreshes them: only the
|
|
173
|
+
* JSON fields `refreshes` names may differ, a `*` standing for any one key.
|
|
174
|
+
*/
|
|
175
|
+
seed: readonly { from: string; to: string; refreshes: readonly string[] }[];
|
|
176
|
+
/**
|
|
177
|
+
* Config files written fresh into the home: pi's shell (X22), and for a pane claude's
|
|
178
|
+
* onboarding, trust and bypass answers and codex's folder trust (H3).
|
|
179
|
+
*/
|
|
180
|
+
defaults(cwd: string): readonly { path: string; contents: string }[];
|
|
181
|
+
/**
|
|
182
|
+
* Credentials passed in the environment instead of a file, by variable: claude's
|
|
183
|
+
* `CLAUDE_CODE_OAUTH_TOKEN`, read from the engine's. Never on a command line.
|
|
184
|
+
*/
|
|
185
|
+
secrets: Readonly<Record<string, string>>;
|
|
186
|
+
/** Model domains, for this agent's model and provider. */
|
|
187
|
+
domains: readonly Domain[];
|
|
188
|
+
/**
|
|
189
|
+
* A variable the provider sets to a directory of this agent's alone whose path is short, for a
|
|
190
|
+
* harness that binds a socket under it: a socket's path holds at most 104 bytes, and a sandbox's
|
|
191
|
+
* homes lie deep. cursor's worker binds one there.
|
|
192
|
+
*/
|
|
193
|
+
shortDirectory?: string;
|
|
194
|
+
/**
|
|
195
|
+
* Host directories this agent writes outside its sandbox's own, shared with whatever else uses
|
|
196
|
+
* them: cursor's resume locks, at a path its code fixes in `/tmp`. The provider makes each, 0700,
|
|
197
|
+
* where it is missing. A box's `/tmp` is its own, so docker needs none of them.
|
|
198
|
+
*/
|
|
199
|
+
sharedWrites?: readonly string[];
|
|
200
|
+
/**
|
|
201
|
+
* The executable's name: `launch` receives it as `argv[0]`. Docker finds it on the image's `PATH`.
|
|
202
|
+
*/
|
|
203
|
+
command: string;
|
|
204
|
+
/**
|
|
205
|
+
* Its real path on the host, which srt runs in `command`'s place: `~/.local/bin` and its links
|
|
206
|
+
* are denied. Found on the engine's `PATH`, which srt gives the agent too.
|
|
207
|
+
*/
|
|
208
|
+
executable: string;
|
|
209
|
+
/**
|
|
210
|
+
* Host paths srt must allow for the command to run: its install tree and interpreter. Never a
|
|
211
|
+
* harness state root, though an install tree may lie inside one (codex's `~/.codex/packages/…`).
|
|
212
|
+
*/
|
|
213
|
+
reads: readonly string[];
|
|
214
|
+
};
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { SandboxContext } from "./seam";
|
|
5
|
+
|
|
6
|
+
/** `value` as one word to a POSIX shell, whatever it holds. */
|
|
7
|
+
export function shellQuote(value: string): string {
|
|
8
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `values` as lines a shell sources with `set -a`, each name checked as a variable's. */
|
|
12
|
+
export function secretsText(values: Readonly<Record<string, string>>): string {
|
|
13
|
+
return Object.entries(values)
|
|
14
|
+
.map(([key, value]) => {
|
|
15
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
16
|
+
throw new Error(`not an environment variable name: ${key}`);
|
|
17
|
+
}
|
|
18
|
+
return `${key}=${shellQuote(value)}\n`;
|
|
19
|
+
})
|
|
20
|
+
.join("");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a sandbox's pane shells find their secrets: beside the sandbox's directory, not in it,
|
|
25
|
+
* as every agent in the sandbox reads that directory, and inside the run root, which each of them
|
|
26
|
+
* is denied.
|
|
27
|
+
*/
|
|
28
|
+
export function secretsDirectory(context: SandboxContext): string {
|
|
29
|
+
return `${context.directory}.secrets`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Writes `values` as a file a shell sources and then deletes, so no token is on a command line
|
|
34
|
+
* (H5). The file is new, by its own name, and the operator's alone: nothing an agent planted is
|
|
35
|
+
* followed or reused.
|
|
36
|
+
*/
|
|
37
|
+
export async function writeSecrets(
|
|
38
|
+
context: SandboxContext,
|
|
39
|
+
name: string,
|
|
40
|
+
values: Readonly<Record<string, string>>,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const directory = secretsDirectory(context);
|
|
43
|
+
await mkdir(directory, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => {
|
|
44
|
+
if (error.code !== "EEXIST") throw error;
|
|
45
|
+
});
|
|
46
|
+
const found = await lstat(directory);
|
|
47
|
+
if (!found.isDirectory() || (found.mode & 0o077) !== 0) {
|
|
48
|
+
throw new Error(`${directory} is not a private directory`);
|
|
49
|
+
}
|
|
50
|
+
const path = join(directory, name);
|
|
51
|
+
const handle = await open(
|
|
52
|
+
path,
|
|
53
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
54
|
+
0o600,
|
|
55
|
+
);
|
|
56
|
+
try {
|
|
57
|
+
await handle.writeFile(secretsText(values));
|
|
58
|
+
} finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Forgets every secret a sandbox's panes did not read. */
|
|
65
|
+
export async function removeSecrets(context: SandboxContext): Promise<void> {
|
|
66
|
+
await rm(secretsDirectory(context), { recursive: true, force: true });
|
|
67
|
+
}
|