@agimon-ai/po-sandbox 0.0.0-alpha.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 +170 -0
- package/dist/apiContracts.cjs +17 -0
- package/dist/apiContracts.cjs.map +1 -0
- package/dist/apiContracts.d.mts +53 -0
- package/dist/apiContracts.d.mts.map +1 -0
- package/dist/apiContracts.mjs +13 -0
- package/dist/apiContracts.mjs.map +1 -0
- package/dist/cockpitHarness.cjs +600 -0
- package/dist/cockpitHarness.cjs.map +1 -0
- package/dist/cockpitHarness.d.mts +41 -0
- package/dist/cockpitHarness.d.mts.map +1 -0
- package/dist/cockpitHarness.mjs +594 -0
- package/dist/cockpitHarness.mjs.map +1 -0
- package/dist/extensionService-CoqM-IE1.cjs +25 -0
- package/dist/extensionService-CoqM-IE1.cjs.map +1 -0
- package/dist/extensionService-FmuCugPK.mjs +20 -0
- package/dist/extensionService-FmuCugPK.mjs.map +1 -0
- package/dist/extensions/mcp.cjs +26 -0
- package/dist/extensions/mcp.cjs.map +1 -0
- package/dist/extensions/mcp.d.mts +5 -0
- package/dist/extensions/mcp.d.mts.map +1 -0
- package/dist/extensions/mcp.mjs +21 -0
- package/dist/extensions/mcp.mjs.map +1 -0
- package/dist/extensions/server.cjs +476 -0
- package/dist/extensions/server.cjs.map +1 -0
- package/dist/extensions/server.d.mts +5 -0
- package/dist/extensions/server.d.mts.map +1 -0
- package/dist/extensions/server.mjs +466 -0
- package/dist/extensions/server.mjs.map +1 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/sandboxBridge-77zC5pQR.mjs +13 -0
- package/dist/sandboxBridge-77zC5pQR.mjs.map +1 -0
- package/dist/sandboxBridge-Db6DubLC.cjs +81 -0
- package/dist/sandboxBridge-Db6DubLC.cjs.map +1 -0
- package/llms.txt +11 -0
- package/package.json +117 -0
- package/src/prompts/po-use-sandbox/SKILL.md +14 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { a as BROKER_PROVIDERS_ENV, i as BROKER_PORT_ENV, n as BROKER_CONTAINER_PORT, o as BROKER_SOCKET_CONTAINER_PATH, r as BROKER_HOST_GATEWAY, s as BROKER_SOCKET_ENV, t as BROKER_ADDRESS_ENV } from "./sandboxBridge-77zC5pQR.mjs";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { PO_SANDBOX_ENV } from "@agimon-ai/po-core/sandboxHarness";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
//#region src/services/oauthCallback/index.ts
|
|
9
|
+
const OAUTH_CALLBACK_HOST_ENV = "PI_OAUTH_CALLBACK_HOST";
|
|
10
|
+
/**
|
|
11
|
+
* Address the in-container callback server binds.
|
|
12
|
+
*
|
|
13
|
+
* Pi binds loopback by default, which a published port cannot reach: the
|
|
14
|
+
* engine forwards to the container's external interface, not its loopback.
|
|
15
|
+
* Only the bind address changes; the redirect the provider sees stays
|
|
16
|
+
* `localhost`.
|
|
17
|
+
*/
|
|
18
|
+
const OAUTH_CONTAINER_BIND = "0.0.0.0";
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/services/sandboxEnvironment/index.ts
|
|
21
|
+
const EXACT_NAMES = /* @__PURE__ */ new Set([
|
|
22
|
+
"TERM",
|
|
23
|
+
"COLORTERM",
|
|
24
|
+
"LANG",
|
|
25
|
+
"TZ",
|
|
26
|
+
"PO_PRESET",
|
|
27
|
+
"ELICITATION_SESSION_ID",
|
|
28
|
+
"HTTP_PROXY",
|
|
29
|
+
"HTTPS_PROXY",
|
|
30
|
+
"NO_PROXY",
|
|
31
|
+
"http_proxy",
|
|
32
|
+
"https_proxy",
|
|
33
|
+
"no_proxy"
|
|
34
|
+
]);
|
|
35
|
+
const NAME_PREFIXES = ["LC_"];
|
|
36
|
+
const NAME_SUFFIXES = [
|
|
37
|
+
"_API_KEY",
|
|
38
|
+
"_AUTH_TOKEN",
|
|
39
|
+
"_BASE_URL"
|
|
40
|
+
];
|
|
41
|
+
/** True for variables shaped like a provider credential or endpoint override. */
|
|
42
|
+
function isCredentialEnvName(name) {
|
|
43
|
+
return NAME_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
44
|
+
}
|
|
45
|
+
function allowed(name) {
|
|
46
|
+
return EXACT_NAMES.has(name) || NAME_PREFIXES.some((prefix) => name.startsWith(prefix)) || NAME_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Projects the host environment down to what a sandboxed session may see.
|
|
50
|
+
*
|
|
51
|
+
* The list is an allowlist on purpose: everything a terminal accumulates
|
|
52
|
+
* (agents, sockets, tokens, paths) stays on the host unless a rule names it.
|
|
53
|
+
* Provider credentials pass through for now; the credential broker planned for
|
|
54
|
+
* the client-server phase removes even those from the container.
|
|
55
|
+
*/
|
|
56
|
+
function filterSandboxEnvironment(environment) {
|
|
57
|
+
const filtered = {};
|
|
58
|
+
for (const [name, value] of Object.entries(environment)) if (value !== void 0 && allowed(name)) filtered[name] = value;
|
|
59
|
+
return filtered;
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/services/containerEnvironment/index.ts
|
|
63
|
+
const ENABLED = "1";
|
|
64
|
+
/**
|
|
65
|
+
* Replaces every credential in the container with the broker's session token.
|
|
66
|
+
*
|
|
67
|
+
* Dropping all credential-shaped variables first is what makes the promise
|
|
68
|
+
* hold for providers the broker does not carry: an unbrokered key would
|
|
69
|
+
* otherwise stay readable inside the container.
|
|
70
|
+
*/
|
|
71
|
+
function brokeredEnvironment(filtered, broker) {
|
|
72
|
+
const environment = {};
|
|
73
|
+
for (const [name, value] of Object.entries(filtered)) if (!isCredentialEnvName(name)) environment[name] = value;
|
|
74
|
+
for (const name of broker.withheldEnv) environment[name] = broker.token;
|
|
75
|
+
if (broker.endpoint.transport === "unix") {
|
|
76
|
+
environment[BROKER_SOCKET_ENV] = BROKER_SOCKET_CONTAINER_PATH;
|
|
77
|
+
environment[BROKER_PORT_ENV] = String(BROKER_CONTAINER_PORT);
|
|
78
|
+
} else environment[BROKER_ADDRESS_ENV] = `${BROKER_HOST_GATEWAY}:${broker.endpoint.port}`;
|
|
79
|
+
environment[BROKER_PROVIDERS_ENV] = broker.providers.join(",");
|
|
80
|
+
return environment;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The environment a sandboxed session runs with: the allowlisted host
|
|
84
|
+
* variables, with every credential substituted by the broker token.
|
|
85
|
+
*/
|
|
86
|
+
function containerEnvironment(environment, broker, loginPorts = []) {
|
|
87
|
+
const filtered = filterSandboxEnvironment(environment);
|
|
88
|
+
const resolved = broker ? brokeredEnvironment(filtered, broker) : filtered;
|
|
89
|
+
resolved[PO_SANDBOX_ENV] = ENABLED;
|
|
90
|
+
if (loginPorts.length > 0) resolved[OAUTH_CALLBACK_HOST_ENV] = OAUTH_CONTAINER_BIND;
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/services/devcontainer/index.ts
|
|
95
|
+
const DEVCONTAINER_DISABLED_ENV = "PO_SANDBOX_DEVCONTAINER";
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/services/cockpitPlan/index.ts
|
|
98
|
+
/**
|
|
99
|
+
* Projects the cockpit into one detached engine run.
|
|
100
|
+
*
|
|
101
|
+
* Unlike a disposable container wrapped around a single interactive session,
|
|
102
|
+
* this plan describes a long-lived container holding
|
|
103
|
+
* the hub and every session it goes on to spawn, which changes four things:
|
|
104
|
+
* it is detached rather than attached, it publishes a port rather than only
|
|
105
|
+
* consuming them, its home volume is scoped to the cockpit rather than to one
|
|
106
|
+
* repository, and it mounts a set of workspaces rather than a single repo.
|
|
107
|
+
*/
|
|
108
|
+
const CONTAINER_HOME = "/po-home";
|
|
109
|
+
/**
|
|
110
|
+
* One volume for the cockpit, not one per repository.
|
|
111
|
+
*
|
|
112
|
+
* A cockpit spans several workspaces, so there is no repository to key on. This
|
|
113
|
+
* is also where `~/.po/web` lands, which is what makes paired passkeys and
|
|
114
|
+
* the bundle signing key survive a container restart. A rotated signing key
|
|
115
|
+
* would make every paired device refuse the cockpit, so the name must be
|
|
116
|
+
* stable.
|
|
117
|
+
*/
|
|
118
|
+
const COCKPIT_HOME_VOLUME = "po-cockpit-home";
|
|
119
|
+
const COCKPIT_LABEL = "po.cockpit";
|
|
120
|
+
const COCKPIT_BINARY = "po-web";
|
|
121
|
+
const LOOPBACK = "127.0.0.1";
|
|
122
|
+
/**
|
|
123
|
+
* The cockpit binds every interface inside the container.
|
|
124
|
+
*
|
|
125
|
+
* A published port reaches the container's external interface, never its
|
|
126
|
+
* loopback, which is the same reason `oauthCallback.ts` sets its own bind
|
|
127
|
+
* address. The host side of the publish is pinned to loopback, so this is not
|
|
128
|
+
* exposed beyond the machine.
|
|
129
|
+
*/
|
|
130
|
+
const CONTAINER_BIND = "0.0.0.0";
|
|
131
|
+
const LINUX_PLATFORM = "linux";
|
|
132
|
+
const DISABLED = "0";
|
|
133
|
+
/**
|
|
134
|
+
* Git identity, passed as environment rather than by mounting a config.
|
|
135
|
+
*
|
|
136
|
+
* The agent can commit; it cannot push, because no key crosses the boundary.
|
|
137
|
+
* Mounting `~/.gitconfig` would drag in whatever else it configures, and
|
|
138
|
+
* forwarding an ssh agent would hand the container a live credential that
|
|
139
|
+
* authenticates as the user everywhere their key reaches.
|
|
140
|
+
*/
|
|
141
|
+
function gitIdentityEnvironment(identity) {
|
|
142
|
+
if (identity === void 0) return {};
|
|
143
|
+
return {
|
|
144
|
+
GIT_AUTHOR_NAME: identity.name,
|
|
145
|
+
GIT_AUTHOR_EMAIL: identity.email,
|
|
146
|
+
GIT_COMMITTER_NAME: identity.name,
|
|
147
|
+
GIT_COMMITTER_EMAIL: identity.email
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function buildCockpitPlan(input) {
|
|
151
|
+
const { engine, host, broker, imageTag, port } = input;
|
|
152
|
+
if (input.workspaces.length === 0) throw new Error("A cockpit container needs at least one workspace to mount.");
|
|
153
|
+
const environment = {
|
|
154
|
+
...containerEnvironment(input.environment, broker),
|
|
155
|
+
...gitIdentityEnvironment(input.gitIdentity),
|
|
156
|
+
[DEVCONTAINER_DISABLED_ENV]: DISABLED,
|
|
157
|
+
[PO_SANDBOX_ENV]: "1"
|
|
158
|
+
};
|
|
159
|
+
const environmentPairs = Object.entries(environment).sort(([left], [right]) => left.localeCompare(right));
|
|
160
|
+
return {
|
|
161
|
+
imageTag,
|
|
162
|
+
runArgs: [
|
|
163
|
+
"run",
|
|
164
|
+
"-d",
|
|
165
|
+
"--label",
|
|
166
|
+
`${COCKPIT_LABEL}=${String(port)}`,
|
|
167
|
+
"-p",
|
|
168
|
+
`${LOOPBACK}:${String(port)}:${String(port)}`,
|
|
169
|
+
...input.workspaces.flatMap((workspace) => ["-v", `${workspace.path}:${workspace.path}`]),
|
|
170
|
+
"-v",
|
|
171
|
+
`${COCKPIT_HOME_VOLUME}:${CONTAINER_HOME}`,
|
|
172
|
+
...broker?.endpoint.transport === "tcp" ? ["--add-host", `host.docker.internal:host-gateway`] : [],
|
|
173
|
+
...broker?.endpoint.transport === "unix" ? ["-v", `${broker.endpoint.socketDirectory}:/run/po`] : [],
|
|
174
|
+
"-w",
|
|
175
|
+
input.workspaces[0]?.path ?? CONTAINER_HOME,
|
|
176
|
+
"-e",
|
|
177
|
+
`HOME=${CONTAINER_HOME}`,
|
|
178
|
+
...environmentPairs.flatMap(([name, value]) => ["-e", `${name}=${value}`]),
|
|
179
|
+
...host.platform === LINUX_PLATFORM && host.userId !== void 0 ? [
|
|
180
|
+
"--user",
|
|
181
|
+
`${String(host.userId)}:${String(host.groupId ?? host.userId)}`,
|
|
182
|
+
...engine === "podman" ? ["--userns=keep-id"] : []
|
|
183
|
+
] : [],
|
|
184
|
+
...input.runFlags ?? [],
|
|
185
|
+
imageTag,
|
|
186
|
+
COCKPIT_BINARY,
|
|
187
|
+
"--host",
|
|
188
|
+
CONTAINER_BIND,
|
|
189
|
+
"--port",
|
|
190
|
+
String(port)
|
|
191
|
+
]
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/services/engineProcess/index.ts
|
|
196
|
+
const INHERIT = "inherit";
|
|
197
|
+
const PIPE = "pipe";
|
|
198
|
+
const IGNORE = "ignore";
|
|
199
|
+
const FORWARDED_SIGNALS = [
|
|
200
|
+
"SIGINT",
|
|
201
|
+
"SIGTERM",
|
|
202
|
+
"SIGHUP"
|
|
203
|
+
];
|
|
204
|
+
/** Spawns the container engine, keeping the session's terminal attached. */
|
|
205
|
+
var SpawnEngineProcessRunner = class {
|
|
206
|
+
run(command, args, options = {}) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const child = spawn(command, args, { stdio: options.input === void 0 ? INHERIT : [
|
|
209
|
+
PIPE,
|
|
210
|
+
INHERIT,
|
|
211
|
+
INHERIT
|
|
212
|
+
] });
|
|
213
|
+
if (options.input !== void 0) child.stdin?.end(options.input);
|
|
214
|
+
const forwarders = FORWARDED_SIGNALS.map((signal) => {
|
|
215
|
+
const forward = () => {
|
|
216
|
+
child.kill(signal);
|
|
217
|
+
};
|
|
218
|
+
process.on(signal, forward);
|
|
219
|
+
return {
|
|
220
|
+
signal,
|
|
221
|
+
forward
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
const cleanup = () => {
|
|
225
|
+
for (const { signal, forward } of forwarders) process.off(signal, forward);
|
|
226
|
+
};
|
|
227
|
+
child.once("error", (error) => {
|
|
228
|
+
cleanup();
|
|
229
|
+
reject(error);
|
|
230
|
+
});
|
|
231
|
+
child.once("exit", (code, signal) => {
|
|
232
|
+
cleanup();
|
|
233
|
+
resolve(code ?? (signal ? 1 : 0));
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
capture(command, args) {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
const child = spawn(command, args, { stdio: [
|
|
240
|
+
IGNORE,
|
|
241
|
+
PIPE,
|
|
242
|
+
IGNORE
|
|
243
|
+
] });
|
|
244
|
+
let stdout = "";
|
|
245
|
+
child.stdout?.on("data", (chunk) => {
|
|
246
|
+
stdout += chunk.toString();
|
|
247
|
+
});
|
|
248
|
+
child.once("error", () => {
|
|
249
|
+
resolve(void 0);
|
|
250
|
+
});
|
|
251
|
+
child.once("exit", (code) => {
|
|
252
|
+
resolve({
|
|
253
|
+
exitCode: code ?? 1,
|
|
254
|
+
stdout
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/services/harness/index.ts
|
|
262
|
+
const ENGINE_ENV = "PO_SANDBOX_ENGINE";
|
|
263
|
+
const ENGINES = [
|
|
264
|
+
"docker",
|
|
265
|
+
"podman",
|
|
266
|
+
"nerdctl",
|
|
267
|
+
"finch"
|
|
268
|
+
];
|
|
269
|
+
const DOCKERFILE_NAME = "Dockerfile";
|
|
270
|
+
async function detectEngine(runner, environment) {
|
|
271
|
+
const configured = environment[ENGINE_ENV]?.trim();
|
|
272
|
+
if (configured) {
|
|
273
|
+
if (ENGINES.includes(configured)) return configured;
|
|
274
|
+
throw new Error(`${ENGINE_ENV} must be one of: ${ENGINES.join(", ")}.`);
|
|
275
|
+
}
|
|
276
|
+
for (const engine of ENGINES) if ((await runner.capture(engine, ["--version"]))?.exitCode === 0) return engine;
|
|
277
|
+
throw new Error(`No container engine found. Install one of ${ENGINES.join(", ")}, or set ${ENGINE_ENV}.`);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Builds the image if the engine does not already have it.
|
|
281
|
+
*
|
|
282
|
+
* Takes its definition rather than assuming one, because the interactive
|
|
283
|
+
* sandbox and the cockpit container are two different images built the same
|
|
284
|
+
* way. The tag already carries a digest of the definition, so a changed
|
|
285
|
+
* Dockerfile misses the inspect and rebuilds.
|
|
286
|
+
*/
|
|
287
|
+
async function ensureImage(runner, engine, version, onProgress, definition) {
|
|
288
|
+
const tag = definition.tag;
|
|
289
|
+
if ((await runner.capture(engine, [
|
|
290
|
+
"image",
|
|
291
|
+
"inspect",
|
|
292
|
+
tag
|
|
293
|
+
]))?.exitCode === 0) return tag;
|
|
294
|
+
onProgress?.(`building ${tag} (first run for this Po version)`);
|
|
295
|
+
const context = fs.mkdtempSync(path.join(os.tmpdir(), "po-sandbox-build-"));
|
|
296
|
+
try {
|
|
297
|
+
fs.writeFileSync(path.join(context, DOCKERFILE_NAME), definition.dockerfile);
|
|
298
|
+
for (const [name, contents] of Object.entries(definition.files ?? {})) fs.writeFileSync(path.join(context, name), contents);
|
|
299
|
+
const exitCode = await runner.run(engine, [
|
|
300
|
+
"build",
|
|
301
|
+
"-t",
|
|
302
|
+
tag,
|
|
303
|
+
"--build-arg",
|
|
304
|
+
`PO_VERSION=${version}`,
|
|
305
|
+
context
|
|
306
|
+
]);
|
|
307
|
+
if (exitCode !== 0) throw new Error(`Building the ${tag} image failed with exit code ${exitCode}.`);
|
|
308
|
+
} finally {
|
|
309
|
+
fs.rmSync(context, {
|
|
310
|
+
recursive: true,
|
|
311
|
+
force: true
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return tag;
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/services/runFlags/index.ts
|
|
318
|
+
/**
|
|
319
|
+
* Splits configured engine options into argv entries.
|
|
320
|
+
*
|
|
321
|
+
* Whitespace separated rather than shell parsed: the engine is spawned without
|
|
322
|
+
* a shell, so quoting rules would only invent a syntax the caller does not
|
|
323
|
+
* actually get.
|
|
324
|
+
*/
|
|
325
|
+
function parseRunFlags(configured) {
|
|
326
|
+
return (configured ?? "").split(/\s+/).filter((flag) => flag.length > 0);
|
|
327
|
+
}
|
|
328
|
+
const OPTION_FORM = /^--?[^\s=]+(=.*)?$/s;
|
|
329
|
+
/**
|
|
330
|
+
* Requires every configured entry to be a self-contained option.
|
|
331
|
+
*
|
|
332
|
+
* Separated values cannot be told from positional arguments without knowing
|
|
333
|
+
* each option's arity, and a stray positional would silently replace the image
|
|
334
|
+
* or its command. Demanding `--flag=value` removes the ambiguity instead of
|
|
335
|
+
* guessing at it.
|
|
336
|
+
*/
|
|
337
|
+
function assertRunFlags(flags, optionName) {
|
|
338
|
+
const invalid = flags.find((flag) => !OPTION_FORM.test(flag));
|
|
339
|
+
if (invalid !== void 0) throw new Error(`${optionName} accepts engine options in --flag or --flag=value form; "${invalid}" is neither. Write a value as part of its option, for example --runtime=runsc.`);
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/services/sandboxImage/index.ts
|
|
343
|
+
const IMAGE_REPOSITORY = "po-sandbox";
|
|
344
|
+
const TAG_SAFE = /[^A-Za-z0-9_.-]/g;
|
|
345
|
+
/**
|
|
346
|
+
* Names one image build: a distribution version plus its image definition.
|
|
347
|
+
*
|
|
348
|
+
* The digest matters as much as the version. Editing the Dockerfile or the
|
|
349
|
+
* bridge without it would silently reuse a cached image that predates the
|
|
350
|
+
* change.
|
|
351
|
+
*/
|
|
352
|
+
function formatImageTag(version, digest) {
|
|
353
|
+
return `${IMAGE_REPOSITORY}:v${version.replaceAll(TAG_SAFE, "-")}-${digest}`;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Image for the cockpit container, which holds the hub rather than one session.
|
|
357
|
+
*
|
|
358
|
+
* Kept separate from the sandbox image rather than folded into it. The cockpit
|
|
359
|
+
* needs the web package and a tunnel client, and making every interactive
|
|
360
|
+
* `--sandbox` launch pay for an extra apt repository and a larger download to
|
|
361
|
+
* get them would be a poor trade.
|
|
362
|
+
*
|
|
363
|
+
* Installing `@agimon-ai/po-web` is enough for all three programs: its
|
|
364
|
+
* `@agimon-ai/po` dependency owns both the session server executable and
|
|
365
|
+
* the agent runtime.
|
|
366
|
+
*/
|
|
367
|
+
function cockpitDockerfile() {
|
|
368
|
+
return [
|
|
369
|
+
"FROM node:22-bookworm-slim",
|
|
370
|
+
"RUN apt-get update \\",
|
|
371
|
+
" && apt-get install -y --no-install-recommends ca-certificates curl git gnupg nano openssh-client ripgrep \\",
|
|
372
|
+
" && rm -rf /var/lib/apt/lists/*",
|
|
373
|
+
"RUN curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \\",
|
|
374
|
+
" -o /usr/share/keyrings/cloudflare-main.gpg \\",
|
|
375
|
+
" && echo \"deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main\" \\",
|
|
376
|
+
" > /etc/apt/sources.list.d/cloudflared.list \\",
|
|
377
|
+
" && apt-get update && apt-get install -y --no-install-recommends cloudflared \\",
|
|
378
|
+
" && rm -rf /var/lib/apt/lists/*",
|
|
379
|
+
"ARG PO_VERSION",
|
|
380
|
+
"RUN npm install -g @agimon-ai/po-web@${PO_VERSION}",
|
|
381
|
+
"# World-writable so the launch can map any host user id onto it.",
|
|
382
|
+
"RUN mkdir -m 0777 /po-home",
|
|
383
|
+
"ENV PO_SANDBOX=1 HOME=/po-home",
|
|
384
|
+
""
|
|
385
|
+
].join("\n");
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/services/sandboxImageTag/index.ts
|
|
389
|
+
const DIGEST_LENGTH = 8;
|
|
390
|
+
/**
|
|
391
|
+
* Tags the cockpit image separately from the sandbox one.
|
|
392
|
+
*
|
|
393
|
+
* Same digest scheme, different definition, so editing either Dockerfile
|
|
394
|
+
* invalidates only its own image.
|
|
395
|
+
*/
|
|
396
|
+
function cockpitImageTag(version) {
|
|
397
|
+
const digest = createHash("sha256").update(cockpitDockerfile()).digest("hex").slice(0, DIGEST_LENGTH);
|
|
398
|
+
return formatImageTag(`${version}-cockpit`, digest);
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region src/services/cockpitHarness/index.ts
|
|
402
|
+
/**
|
|
403
|
+
* Runs the cockpit itself in a container, detached, holding the hub and every
|
|
404
|
+
* session it goes on to spawn.
|
|
405
|
+
*
|
|
406
|
+
* The interactive harness next door wraps one session and blocks until it
|
|
407
|
+
* exits. This one returns a handle while the container keeps running, which is
|
|
408
|
+
* why it is a separate entry rather than a flag on the other.
|
|
409
|
+
*/
|
|
410
|
+
const RUN_FLAGS_ENV = "PO_SANDBOX_RUN_FLAGS";
|
|
411
|
+
const READY_TIMEOUT_MS = 6e4;
|
|
412
|
+
const READY_POLL_MS = 250;
|
|
413
|
+
const HUB_ROLE = "hub";
|
|
414
|
+
/**
|
|
415
|
+
* Reads the host's committer identity.
|
|
416
|
+
*
|
|
417
|
+
* Passed into the container as environment rather than by mounting the config,
|
|
418
|
+
* so the agent can commit without any credential crossing the boundary. It
|
|
419
|
+
* cannot push, and that is the intended limit.
|
|
420
|
+
*/
|
|
421
|
+
function readGitIdentity() {
|
|
422
|
+
const read = (key) => {
|
|
423
|
+
try {
|
|
424
|
+
const value = execFileSync("git", [
|
|
425
|
+
"config",
|
|
426
|
+
"--global",
|
|
427
|
+
"--get",
|
|
428
|
+
key
|
|
429
|
+
], { encoding: "utf8" }).trim();
|
|
430
|
+
return value === "" ? void 0 : value;
|
|
431
|
+
} catch {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const name = read("user.name");
|
|
436
|
+
const email = read("user.email");
|
|
437
|
+
return name !== void 0 && email !== void 0 ? {
|
|
438
|
+
name,
|
|
439
|
+
email
|
|
440
|
+
} : void 0;
|
|
441
|
+
}
|
|
442
|
+
/** Answers whether the hub inside the container is serving yet. */
|
|
443
|
+
async function defaultProbe(port) {
|
|
444
|
+
try {
|
|
445
|
+
const response = await fetch(`http://127.0.0.1:${String(port)}/api/health`);
|
|
446
|
+
if (!response.ok) return false;
|
|
447
|
+
return (await response.json()).role === HUB_ROLE;
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function stopContainer(runner, engine, id) {
|
|
453
|
+
const stopped = await runner.capture(engine, ["stop", id]);
|
|
454
|
+
await runner.capture(engine, [
|
|
455
|
+
"rm",
|
|
456
|
+
"-f",
|
|
457
|
+
id
|
|
458
|
+
]);
|
|
459
|
+
return stopped?.exitCode === 0;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Whether the engine still reports this container as running.
|
|
463
|
+
*
|
|
464
|
+
* A removed container makes `inspect` fail rather than print `false`, so a
|
|
465
|
+
* non-zero exit is read the same way: not running.
|
|
466
|
+
*/
|
|
467
|
+
async function isRunning(runner, engine, id) {
|
|
468
|
+
const probed = await runner.capture(engine, [
|
|
469
|
+
"inspect",
|
|
470
|
+
"-f",
|
|
471
|
+
"{{.State.Running}}",
|
|
472
|
+
id
|
|
473
|
+
]);
|
|
474
|
+
return probed?.exitCode === 0 && probed.stdout.trim() === "true";
|
|
475
|
+
}
|
|
476
|
+
function createCockpitHarness(dependencies = {}) {
|
|
477
|
+
const runner = dependencies.runner ?? new SpawnEngineProcessRunner();
|
|
478
|
+
const probe = dependencies.probe ?? defaultProbe;
|
|
479
|
+
const readIdentity = dependencies.gitIdentity ?? readGitIdentity;
|
|
480
|
+
const now = dependencies.now ?? (() => Date.now());
|
|
481
|
+
return {
|
|
482
|
+
async startCockpitContainer(request) {
|
|
483
|
+
if (request.workspaces.length === 0) return {
|
|
484
|
+
ok: false,
|
|
485
|
+
error: "A cockpit container needs at least one workspace to mount."
|
|
486
|
+
};
|
|
487
|
+
let engine;
|
|
488
|
+
try {
|
|
489
|
+
engine = await detectEngine(runner, request.environment);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
error: error instanceof Error ? error.message : String(error)
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const runFlags = parseRunFlags(request.environment[RUN_FLAGS_ENV]);
|
|
497
|
+
try {
|
|
498
|
+
assertRunFlags(runFlags, RUN_FLAGS_ENV);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
error: error instanceof Error ? error.message : String(error)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
request.onProgress?.(`using ${engine}${runFlags.length > 0 ? ` with ${runFlags.join(" ")}` : ""}`);
|
|
506
|
+
const version = dependencies.version ?? "0.0.0";
|
|
507
|
+
let imageTag;
|
|
508
|
+
try {
|
|
509
|
+
imageTag = await ensureImage(runner, engine, version, request.onProgress, {
|
|
510
|
+
tag: cockpitImageTag(version),
|
|
511
|
+
dockerfile: cockpitDockerfile()
|
|
512
|
+
});
|
|
513
|
+
} catch (error) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
error: error instanceof Error ? error.message : String(error)
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
const plan = buildCockpitPlan({
|
|
520
|
+
workspaces: request.workspaces,
|
|
521
|
+
port: request.port,
|
|
522
|
+
environment: request.environment,
|
|
523
|
+
engine,
|
|
524
|
+
host: {
|
|
525
|
+
hasTty: false,
|
|
526
|
+
platform: process.platform,
|
|
527
|
+
repoKey: "",
|
|
528
|
+
version
|
|
529
|
+
},
|
|
530
|
+
imageTag,
|
|
531
|
+
runFlags,
|
|
532
|
+
...readIdentity() === void 0 ? {} : { gitIdentity: readIdentity() }
|
|
533
|
+
});
|
|
534
|
+
const started = await runner.capture(engine, plan.runArgs);
|
|
535
|
+
const containerId = started?.stdout.trim().split("\n").pop() ?? "";
|
|
536
|
+
if (started?.exitCode !== 0 || containerId === "") return {
|
|
537
|
+
ok: false,
|
|
538
|
+
error: `The cockpit container did not start: ${started?.stdout.trim() ?? "no output"}`
|
|
539
|
+
};
|
|
540
|
+
request.onProgress?.("waiting for the cockpit to answer");
|
|
541
|
+
const deadline = now() + READY_TIMEOUT_MS;
|
|
542
|
+
for (;;) {
|
|
543
|
+
if (await probe(request.port)) break;
|
|
544
|
+
if (now() >= deadline) {
|
|
545
|
+
const logs = await runner.capture(engine, [
|
|
546
|
+
"logs",
|
|
547
|
+
"--tail",
|
|
548
|
+
"20",
|
|
549
|
+
containerId
|
|
550
|
+
]);
|
|
551
|
+
await stopContainer(runner, engine, containerId);
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
error: `The cockpit did not answer within ${String(READY_TIMEOUT_MS / 1e3)}s.\n${logs?.stdout ?? ""}`.trim()
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
ok: true,
|
|
561
|
+
handle: {
|
|
562
|
+
containerId,
|
|
563
|
+
stop: async () => {
|
|
564
|
+
await stopContainer(runner, engine, containerId);
|
|
565
|
+
},
|
|
566
|
+
alive: async () => await isRunning(runner, engine, containerId)
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
},
|
|
570
|
+
async reapCockpitContainer(containerId) {
|
|
571
|
+
for (const engine of [
|
|
572
|
+
"docker",
|
|
573
|
+
"podman",
|
|
574
|
+
"nerdctl",
|
|
575
|
+
"finch"
|
|
576
|
+
]) {
|
|
577
|
+
if ((await runner.capture(engine, ["--version"]))?.exitCode !== 0) continue;
|
|
578
|
+
return await stopContainer(runner, engine, containerId);
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
/** The module-shaped entry a host resolves from the composition. */
|
|
585
|
+
async function startCockpitContainer(request) {
|
|
586
|
+
return await createCockpitHarness().startCockpitContainer(request);
|
|
587
|
+
}
|
|
588
|
+
async function reapCockpitContainer(containerId) {
|
|
589
|
+
return await createCockpitHarness().reapCockpitContainer(containerId);
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
export { createCockpitHarness, reapCockpitContainer, startCockpitContainer };
|
|
593
|
+
|
|
594
|
+
//# sourceMappingURL=cockpitHarness.mjs.map
|