@agimon-ai/doompi-sandbox 0.0.1-alpha.101

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/dist/apiContracts.cjs +17 -0
  4. package/dist/apiContracts.cjs.map +1 -0
  5. package/dist/apiContracts.d.mts +53 -0
  6. package/dist/apiContracts.d.mts.map +1 -0
  7. package/dist/apiContracts.mjs +13 -0
  8. package/dist/apiContracts.mjs.map +1 -0
  9. package/dist/brokerHost-BeGtmnK8.mjs +302 -0
  10. package/dist/brokerHost-BeGtmnK8.mjs.map +1 -0
  11. package/dist/brokerHost-BzffXeb2.cjs +340 -0
  12. package/dist/brokerHost-BzffXeb2.cjs.map +1 -0
  13. package/dist/cockpitHarness.cjs +298 -0
  14. package/dist/cockpitHarness.cjs.map +1 -0
  15. package/dist/cockpitHarness.d.mts +26 -0
  16. package/dist/cockpitHarness.d.mts.map +1 -0
  17. package/dist/cockpitHarness.mjs +295 -0
  18. package/dist/cockpitHarness.mjs.map +1 -0
  19. package/dist/extensionService-BnIZjdJN.cjs +25 -0
  20. package/dist/extensionService-BnIZjdJN.cjs.map +1 -0
  21. package/dist/extensionService-DiVpVLn9.mjs +20 -0
  22. package/dist/extensionService-DiVpVLn9.mjs.map +1 -0
  23. package/dist/extensions/mcp.cjs +26 -0
  24. package/dist/extensions/mcp.cjs.map +1 -0
  25. package/dist/extensions/mcp.d.mts +5 -0
  26. package/dist/extensions/mcp.d.mts.map +1 -0
  27. package/dist/extensions/mcp.mjs +21 -0
  28. package/dist/extensions/mcp.mjs.map +1 -0
  29. package/dist/extensions/pi.cjs +85 -0
  30. package/dist/extensions/pi.cjs.map +1 -0
  31. package/dist/extensions/pi.d.mts +6 -0
  32. package/dist/extensions/pi.d.mts.map +1 -0
  33. package/dist/extensions/pi.mjs +80 -0
  34. package/dist/extensions/pi.mjs.map +1 -0
  35. package/dist/extensions/server.cjs +92 -0
  36. package/dist/extensions/server.cjs.map +1 -0
  37. package/dist/extensions/server.d.mts +5 -0
  38. package/dist/extensions/server.d.mts.map +1 -0
  39. package/dist/extensions/server.mjs +87 -0
  40. package/dist/extensions/server.mjs.map +1 -0
  41. package/dist/harness-CEuhOTr2.mjs +716 -0
  42. package/dist/harness-CEuhOTr2.mjs.map +1 -0
  43. package/dist/harness-fK8CSaU1.cjs +785 -0
  44. package/dist/harness-fK8CSaU1.cjs.map +1 -0
  45. package/dist/index-CBt-PlVG.d.mts +22 -0
  46. package/dist/index-CBt-PlVG.d.mts.map +1 -0
  47. package/dist/index.cjs +3 -0
  48. package/dist/index.d.mts +2 -0
  49. package/dist/index.mjs +2 -0
  50. package/dist/sandboxBridge-B3FRZkC2.cjs +131 -0
  51. package/dist/sandboxBridge-B3FRZkC2.cjs.map +1 -0
  52. package/dist/sandboxBridge-D6RAncmy.mjs +72 -0
  53. package/dist/sandboxBridge-D6RAncmy.mjs.map +1 -0
  54. package/dist/sandboxHarness-BIvuRMio.d.mts +38 -0
  55. package/dist/sandboxHarness-BIvuRMio.d.mts.map +1 -0
  56. package/dist/sandboxHarness.cjs +4 -0
  57. package/dist/sandboxHarness.d.mts +38 -0
  58. package/dist/sandboxHarness.d.mts.map +1 -0
  59. package/dist/sandboxHarness.mjs +2 -0
  60. package/dist/sandboxResources-5pvXyBW9.mjs +79 -0
  61. package/dist/sandboxResources-5pvXyBW9.mjs.map +1 -0
  62. package/dist/sandboxResources-Djxj5l1V.cjs +120 -0
  63. package/dist/sandboxResources-Djxj5l1V.cjs.map +1 -0
  64. package/llms.txt +11 -0
  65. package/package.json +132 -0
  66. package/src/prompts/doompi-use-sandbox/SKILL.md +14 -0
@@ -0,0 +1,716 @@
1
+ import { a as BROKER_HOST_GATEWAY, c as BROKER_SOCKET_CONTAINER_PATH, i as BROKER_CONTAINER_PORT, l as BROKER_SOCKET_ENV, n as BRIDGE_FILE_NAME, o as BROKER_PORT_ENV, r as BROKER_ADDRESS_ENV, s as BROKER_PROVIDERS_ENV, t as BRIDGE_CONTAINER_PATH, u as sandboxBridgeSource } from "./sandboxBridge-D6RAncmy.mjs";
2
+ import { t as startBroker } from "./brokerHost-BeGtmnK8.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { spawn } from "node:child_process";
5
+ import { DOOMPI_SANDBOX_ENV } from "@agimon-ai/doompi-core/sandboxHarness";
6
+ import path from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import net from "node:net";
11
+ //#region src/services/devcontainer/index.ts
12
+ /**
13
+ * Relative locations the Dev Containers spec looks for a configuration.
14
+ *
15
+ * Ordered as the spec resolves them, so the first hit on disk wins.
16
+ */
17
+ const DEVCONTAINER_CONFIG_PATHS = [".devcontainer/devcontainer.json", ".devcontainer.json"];
18
+ const DEVCONTAINER_DISABLED_ENV = "DOOMPI_SANDBOX_DEVCONTAINER";
19
+ const DEVCONTAINER_CLI_PACKAGE = "@devcontainers/cli@0.80.0";
20
+ /** Arguments for bringing the workspace's own dev container up. */
21
+ function devcontainerUpArgs(options) {
22
+ return [
23
+ "up",
24
+ "--workspace-folder",
25
+ options.repoRoot,
26
+ "--log-format",
27
+ "json",
28
+ ...options.socketDirectory && options.socketTarget ? ["--mount", `type=bind,source=${options.socketDirectory},target=${options.socketTarget}`] : []
29
+ ];
30
+ }
31
+ /**
32
+ * Reads the container id out of the CLI's JSON log stream.
33
+ *
34
+ * The stream carries progress records too, so the result is the last record
35
+ * that reports an outcome rather than the first line parsed.
36
+ */
37
+ function parseDevcontainerUp(stdout) {
38
+ let outcome;
39
+ for (const line of stdout.split("\n")) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed.startsWith("{")) continue;
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(trimmed);
45
+ } catch {
46
+ continue;
47
+ }
48
+ if (typeof parsed === "object" && parsed !== null && "outcome" in parsed) outcome = parsed;
49
+ }
50
+ if (!outcome) return { error: "The dev container CLI reported no outcome." };
51
+ if (outcome.outcome !== "success") return { error: outcome.description || outcome.message || `Dev container start reported ${outcome.outcome}.` };
52
+ if (!outcome.containerId) return { error: "The dev container started but reported no container id." };
53
+ return {
54
+ containerId: outcome.containerId,
55
+ remoteWorkspaceFolder: outcome.remoteWorkspaceFolder
56
+ };
57
+ }
58
+ /**
59
+ * Maps a host path into the dev container's own workspace.
60
+ *
61
+ * A dev container mounts the workspace where its configuration says, commonly
62
+ * /workspaces/<name>, so the host path the launch was given does not exist
63
+ * inside and would fail the exec before the agent ever starts.
64
+ */
65
+ function containerWorkspacePath(repoRoot, cwd, remoteWorkspaceFolder) {
66
+ const inside = path.relative(repoRoot, cwd);
67
+ const relative = inside === "" || inside.startsWith("..") || path.isAbsolute(inside) ? "" : inside;
68
+ return relative ? `${remoteWorkspaceFolder}/${relative.split("\\").join("/")}` : remoteWorkspaceFolder;
69
+ }
70
+ /**
71
+ * Installs the distribution inside a dev container that lacks it.
72
+ *
73
+ * The container is the workspace's own, so it carries the project toolchain
74
+ * rather than this layer's, and nothing guarantees DoomPi is present. `up`
75
+ * reuses an existing container, so this is paid once for its lifetime.
76
+ */
77
+ function bootstrapCommand(version) {
78
+ return [
79
+ "sh",
80
+ "-c",
81
+ `command -v doompi >/dev/null 2>&1 && exit 0; command -v npm >/dev/null 2>&1 || { echo "doompi-sandbox: this dev container has no npm to install DoomPi with" >&2; exit 127; }; npm install -g @agimon-ai/doompi@${version} >/dev/null`
82
+ ];
83
+ }
84
+ /**
85
+ * Rewrites forwarded host paths into the dev container's workspace.
86
+ *
87
+ * The launcher replays its resolved options, and several carry absolute host
88
+ * paths: --cwd always, plugin and additional directories when set. Those
89
+ * resolve in the built-in image, which mounts the repository at the path it
90
+ * has on the host, but a dev container mounts it wherever its configuration
91
+ * says, and the session would fail looking for a repository that is not there.
92
+ */
93
+ function mapForwardArgs(args, repoRoot, remoteWorkspaceFolder) {
94
+ return args.map((argument) => argument === repoRoot || argument.startsWith(`${repoRoot}/`) ? containerWorkspacePath(repoRoot, argument, remoteWorkspaceFolder) : argument);
95
+ }
96
+ /**
97
+ * Runs a command in the dev container through the engine rather than the CLI.
98
+ *
99
+ * `devcontainer exec` allocates no terminal, which a full screen TUI needs, so
100
+ * the session is attached to the container the CLI already started.
101
+ */
102
+ function devcontainerExecArgs(options) {
103
+ const environmentPairs = Object.entries(options.environment).sort(([left], [right]) => left.localeCompare(right));
104
+ return [
105
+ "exec",
106
+ "-i",
107
+ ...options.hasTty ? ["-t"] : [],
108
+ "-w",
109
+ options.cwd,
110
+ ...environmentPairs.flatMap(([name, value]) => ["-e", `${name}=${value}`]),
111
+ options.containerId,
112
+ ...options.command
113
+ ];
114
+ }
115
+ //#endregion
116
+ //#region src/services/oauthCallback/index.ts
117
+ /**
118
+ * Fixed loopback ports Pi's OAuth flows listen on.
119
+ *
120
+ * A provider redirects the browser to `http://localhost:<port>`, so the host
121
+ * port has to match exactly and cannot be remapped. Providers that bind an
122
+ * ephemeral port instead, such as OpenRouter, cannot be published ahead of the
123
+ * flow and are not covered.
124
+ */
125
+ const OAUTH_CALLBACK_PORTS = [
126
+ 1455,
127
+ 1456,
128
+ 53692
129
+ ];
130
+ const OAUTH_CALLBACK_HOST_ENV = "PI_OAUTH_CALLBACK_HOST";
131
+ /**
132
+ * Address the in-container callback server binds.
133
+ *
134
+ * Pi binds loopback by default, which a published port cannot reach: the
135
+ * engine forwards to the container's external interface, not its loopback.
136
+ * Only the bind address changes; the redirect the provider sees stays
137
+ * `localhost`.
138
+ */
139
+ const OAUTH_CONTAINER_BIND = "0.0.0.0";
140
+ /** Publishes each reachable callback port back onto the host's loopback. */
141
+ function oauthPublishArgs(ports) {
142
+ return ports.flatMap((port) => ["-p", `127.0.0.1:${port}:${port}`]);
143
+ }
144
+ //#endregion
145
+ //#region src/services/sandboxEnvironment/index.ts
146
+ const EXACT_NAMES = /* @__PURE__ */ new Set([
147
+ "TERM",
148
+ "COLORTERM",
149
+ "LANG",
150
+ "TZ",
151
+ "DOOMPI_PRESET",
152
+ "ELICITATION_SESSION_ID",
153
+ "HTTP_PROXY",
154
+ "HTTPS_PROXY",
155
+ "NO_PROXY",
156
+ "http_proxy",
157
+ "https_proxy",
158
+ "no_proxy"
159
+ ]);
160
+ const NAME_PREFIXES = ["LC_"];
161
+ const NAME_SUFFIXES = [
162
+ "_API_KEY",
163
+ "_AUTH_TOKEN",
164
+ "_BASE_URL"
165
+ ];
166
+ /** True for variables shaped like a provider credential or endpoint override. */
167
+ function isCredentialEnvName(name) {
168
+ return NAME_SUFFIXES.some((suffix) => name.endsWith(suffix));
169
+ }
170
+ function allowed(name) {
171
+ return EXACT_NAMES.has(name) || NAME_PREFIXES.some((prefix) => name.startsWith(prefix)) || NAME_SUFFIXES.some((suffix) => name.endsWith(suffix));
172
+ }
173
+ /**
174
+ * Projects the host environment down to what a sandboxed session may see.
175
+ *
176
+ * The list is an allowlist on purpose: everything a terminal accumulates
177
+ * (agents, sockets, tokens, paths) stays on the host unless a rule names it.
178
+ * Provider credentials pass through for now; the credential broker planned for
179
+ * the client-server phase removes even those from the container.
180
+ */
181
+ function filterSandboxEnvironment(environment) {
182
+ const filtered = {};
183
+ for (const [name, value] of Object.entries(environment)) if (value !== void 0 && allowed(name)) filtered[name] = value;
184
+ return filtered;
185
+ }
186
+ //#endregion
187
+ //#region src/services/sandboxPlan/index.ts
188
+ const CONTAINER_HOME = "/doompi-home";
189
+ const REPOSITORY_LABEL = "doompi.sandbox.repo";
190
+ const LAUNCHER_BINARY$1 = "doompi";
191
+ const NODE_BINARY = "node";
192
+ const LINUX_PLATFORM = "linux";
193
+ const ENABLED = "1";
194
+ const BROKER_MOUNT_DIRECTORY$1 = "/run/doompi";
195
+ /**
196
+ * Replaces every credential in the container with the broker's session token.
197
+ *
198
+ * Dropping all credential-shaped variables first is what makes the promise
199
+ * hold for providers the broker does not carry: an unbrokered key would
200
+ * otherwise stay readable inside the container.
201
+ */
202
+ function brokeredEnvironment(filtered, broker) {
203
+ const environment = {};
204
+ for (const [name, value] of Object.entries(filtered)) if (!isCredentialEnvName(name)) environment[name] = value;
205
+ for (const name of broker.withheldEnv) environment[name] = broker.token;
206
+ if (broker.endpoint.transport === "unix") {
207
+ environment[BROKER_SOCKET_ENV] = BROKER_SOCKET_CONTAINER_PATH;
208
+ environment[BROKER_PORT_ENV] = String(BROKER_CONTAINER_PORT);
209
+ } else environment[BROKER_ADDRESS_ENV] = `${BROKER_HOST_GATEWAY}:${broker.endpoint.port}`;
210
+ environment[BROKER_PROVIDERS_ENV] = broker.providers.join(",");
211
+ return environment;
212
+ }
213
+ /**
214
+ * Grants the container the one route it needs to reach the broker.
215
+ *
216
+ * A mounted socket needs no network at all. TCP needs the engine's host
217
+ * gateway named explicitly, because it is not resolvable by default on every
218
+ * engine.
219
+ */
220
+ function brokerAccessArgs(broker) {
221
+ if (!broker) return [];
222
+ if (broker.endpoint.transport === "unix") return ["-v", `${broker.endpoint.socketDirectory}:${BROKER_MOUNT_DIRECTORY$1}`];
223
+ return ["--add-host", `${BROKER_HOST_GATEWAY}:host-gateway`];
224
+ }
225
+ /**
226
+ * Projects one launch into a disposable engine run.
227
+ *
228
+ * The container is ephemeral; persistence lives in two named volumes: the
229
+ * container home (Pi sessions, sync cache) and a shadow over the repository's
230
+ * `.pi` store, which keeps Linux installs from corrupting the host's own
231
+ * platform-specific packages on the shared workspace mount.
232
+ */
233
+ /**
234
+ * The environment a sandboxed session runs with, whichever container hosts it.
235
+ *
236
+ * Shared with the dev container path, which builds no run arguments of its own
237
+ * but must apply the same allowlist and the same credential substitution.
238
+ */
239
+ function containerEnvironment(environment, broker, loginPorts = []) {
240
+ const filtered = filterSandboxEnvironment(environment);
241
+ const resolved = broker ? brokeredEnvironment(filtered, broker) : filtered;
242
+ resolved[DOOMPI_SANDBOX_ENV] = ENABLED;
243
+ if (loginPorts.length > 0) resolved[OAUTH_CALLBACK_HOST_ENV] = OAUTH_CONTAINER_BIND;
244
+ return resolved;
245
+ }
246
+ function buildSandboxPlan(input) {
247
+ const { repoRoot, cwd, engine, host, broker, imageTag } = input;
248
+ const loginPorts = input.loginPorts ?? [];
249
+ const environmentPairs = Object.entries(containerEnvironment(input.environment, broker, loginPorts)).sort(([left], [right]) => left.localeCompare(right));
250
+ return {
251
+ imageTag,
252
+ runArgs: [
253
+ "run",
254
+ "--rm",
255
+ "-i",
256
+ ...host.hasTty ? ["-t"] : [],
257
+ "--label",
258
+ `${REPOSITORY_LABEL}=${repoRoot}`,
259
+ "-v",
260
+ `${repoRoot}:${repoRoot}`,
261
+ "-v",
262
+ `${host.repoKey}-home:${CONTAINER_HOME}`,
263
+ "-v",
264
+ `${host.repoKey}-pi:${repoRoot}/.pi`,
265
+ ...brokerAccessArgs(broker),
266
+ ...oauthPublishArgs(loginPorts),
267
+ "-w",
268
+ cwd,
269
+ "-e",
270
+ `HOME=${CONTAINER_HOME}`,
271
+ ...environmentPairs.flatMap(([name, value]) => ["-e", `${name}=${value}`]),
272
+ ...host.platform === LINUX_PLATFORM && host.userId !== void 0 ? [
273
+ "--user",
274
+ `${host.userId}:${host.groupId ?? host.userId}`,
275
+ ...engine === "podman" ? ["--userns=keep-id"] : []
276
+ ] : [],
277
+ ...input.runFlags ?? [],
278
+ imageTag,
279
+ ...broker?.endpoint.transport === "unix" ? [NODE_BINARY, BRIDGE_CONTAINER_PATH] : [],
280
+ LAUNCHER_BINARY$1,
281
+ ...input.forwardArgs
282
+ ]
283
+ };
284
+ }
285
+ //#endregion
286
+ //#region src/services/engineProcess/index.ts
287
+ const INHERIT = "inherit";
288
+ const PIPE = "pipe";
289
+ const IGNORE = "ignore";
290
+ const FORWARDED_SIGNALS = [
291
+ "SIGINT",
292
+ "SIGTERM",
293
+ "SIGHUP"
294
+ ];
295
+ /** Spawns the container engine, keeping the session's terminal attached. */
296
+ var SpawnEngineProcessRunner = class {
297
+ run(command, args, options = {}) {
298
+ return new Promise((resolve, reject) => {
299
+ const child = spawn(command, args, { stdio: options.input === void 0 ? INHERIT : [
300
+ PIPE,
301
+ INHERIT,
302
+ INHERIT
303
+ ] });
304
+ if (options.input !== void 0) child.stdin?.end(options.input);
305
+ const forwarders = FORWARDED_SIGNALS.map((signal) => {
306
+ const forward = () => {
307
+ child.kill(signal);
308
+ };
309
+ process.on(signal, forward);
310
+ return {
311
+ signal,
312
+ forward
313
+ };
314
+ });
315
+ const cleanup = () => {
316
+ for (const { signal, forward } of forwarders) process.off(signal, forward);
317
+ };
318
+ child.once("error", (error) => {
319
+ cleanup();
320
+ reject(error);
321
+ });
322
+ child.once("exit", (code, signal) => {
323
+ cleanup();
324
+ resolve(code ?? (signal ? 1 : 0));
325
+ });
326
+ });
327
+ }
328
+ capture(command, args) {
329
+ return new Promise((resolve) => {
330
+ const child = spawn(command, args, { stdio: [
331
+ IGNORE,
332
+ PIPE,
333
+ IGNORE
334
+ ] });
335
+ let stdout = "";
336
+ child.stdout?.on("data", (chunk) => {
337
+ stdout += chunk.toString();
338
+ });
339
+ child.once("error", () => {
340
+ resolve(void 0);
341
+ });
342
+ child.once("exit", (code) => {
343
+ resolve({
344
+ exitCode: code ?? 1,
345
+ stdout
346
+ });
347
+ });
348
+ });
349
+ }
350
+ };
351
+ //#endregion
352
+ //#region src/services/devcontainer/runtime.ts
353
+ const DEVCONTAINER_BINARY = "devcontainer";
354
+ const NPX_BINARY = "npx";
355
+ const LAUNCHER_BINARY = "doompi";
356
+ const BROKER_MOUNT_DIRECTORY = "/run/doompi";
357
+ /** Locates the workspace's own dev container configuration, if it has one. */
358
+ function findDevcontainerConfig(repoRoot) {
359
+ for (const relative of DEVCONTAINER_CONFIG_PATHS) {
360
+ const candidate = path.join(repoRoot, relative);
361
+ if (fs.existsSync(candidate)) return candidate;
362
+ }
363
+ }
364
+ /**
365
+ * Resolves how to invoke the Dev Containers CLI.
366
+ *
367
+ * A local install is preferred so a launch does not depend on the network;
368
+ * npx is the fallback, pinned so the tool cannot change underneath a session.
369
+ */
370
+ async function resolveDevcontainerCli(runner) {
371
+ if ((await runner.capture(DEVCONTAINER_BINARY, ["--version"]))?.exitCode === 0) return {
372
+ command: DEVCONTAINER_BINARY,
373
+ prefix: []
374
+ };
375
+ return {
376
+ command: NPX_BINARY,
377
+ prefix: ["-y", DEVCONTAINER_CLI_PACKAGE]
378
+ };
379
+ }
380
+ /**
381
+ * Runs the session inside the workspace's own dev container.
382
+ *
383
+ * The container is brought up by the Dev Containers CLI, so the file decides
384
+ * the image, features, mounts and lifecycle hooks: the session gets the
385
+ * project's real toolchain, and this layer's isolation guarantees do not
386
+ * apply beyond the environment it passes in.
387
+ */
388
+ async function runDevcontainerSession(options) {
389
+ const cli = await resolveDevcontainerCli(options.runner);
390
+ options.onProgress?.("starting the workspace dev container");
391
+ const upArgs = devcontainerUpArgs({
392
+ repoRoot: options.repoRoot,
393
+ socketDirectory: options.socketDirectory,
394
+ socketTarget: options.socketDirectory ? BROKER_MOUNT_DIRECTORY : void 0
395
+ });
396
+ const up = await options.runner.capture(cli.command, [...cli.prefix, ...upArgs]);
397
+ if (!up) throw new Error("The Dev Containers CLI could not be started. Install @devcontainers/cli, or make npx available.");
398
+ const { containerId, remoteWorkspaceFolder, error } = parseDevcontainerUp(up.stdout);
399
+ if (!containerId) throw new Error(`The dev container did not start: ${error ?? `exit ${up.exitCode}`}`);
400
+ const bootstrap = await options.runner.run(options.engine, [
401
+ "exec",
402
+ containerId,
403
+ ...bootstrapCommand(options.version)
404
+ ]);
405
+ if (bootstrap !== 0) throw new Error(`DoomPi could not be installed in the dev container (exit ${bootstrap}). Add it to the dev container, or unset the dev container preference to use the built-in image.`);
406
+ options.onProgress?.(`attaching to dev container ${containerId.slice(0, 12)}`);
407
+ return options.runner.run(options.engine, devcontainerExecArgs({
408
+ containerId,
409
+ cwd: remoteWorkspaceFolder ? containerWorkspacePath(options.repoRoot, options.cwd, remoteWorkspaceFolder) : options.cwd,
410
+ environment: options.environment,
411
+ hasTty: options.hasTty,
412
+ command: [LAUNCHER_BINARY, ...remoteWorkspaceFolder ? mapForwardArgs(options.forwardArgs, options.repoRoot, remoteWorkspaceFolder) : options.forwardArgs]
413
+ }));
414
+ }
415
+ //#endregion
416
+ //#region src/services/loginPorts/index.ts
417
+ const LOOPBACK = "127.0.0.1";
418
+ function portIsFree(port) {
419
+ return new Promise((resolve) => {
420
+ const probe = net.createServer();
421
+ probe.once("error", () => resolve(false));
422
+ probe.listen(port, LOOPBACK, () => {
423
+ probe.close(() => resolve(true));
424
+ });
425
+ });
426
+ }
427
+ /**
428
+ * Reports which OAuth callback ports this launch may publish.
429
+ *
430
+ * A taken port means another sandbox already holds it, so publishing would
431
+ * fail the whole launch over a login the session may never attempt. Skipping
432
+ * it costs only the ability to log in from inside this particular container.
433
+ */
434
+ async function availableLoginPorts(ports = OAUTH_CALLBACK_PORTS) {
435
+ const free = [];
436
+ for (const port of ports) if (await portIsFree(port)) free.push(port);
437
+ return free;
438
+ }
439
+ //#endregion
440
+ //#region src/services/runFlags/index.ts
441
+ /**
442
+ * Splits configured engine options into argv entries.
443
+ *
444
+ * Whitespace separated rather than shell parsed: the engine is spawned without
445
+ * a shell, so quoting rules would only invent a syntax the caller does not
446
+ * actually get.
447
+ */
448
+ function parseRunFlags(configured) {
449
+ return (configured ?? "").split(/\s+/).filter((flag) => flag.length > 0);
450
+ }
451
+ const OPTION_FORM = /^--?[^\s=]+(=.*)?$/s;
452
+ /**
453
+ * Requires every configured entry to be a self-contained option.
454
+ *
455
+ * Separated values cannot be told from positional arguments without knowing
456
+ * each option's arity, and a stray positional would silently replace the image
457
+ * or its command. Demanding `--flag=value` removes the ambiguity instead of
458
+ * guessing at it.
459
+ */
460
+ function assertRunFlags(flags, optionName) {
461
+ const invalid = flags.find((flag) => !OPTION_FORM.test(flag));
462
+ 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.`);
463
+ }
464
+ //#endregion
465
+ //#region src/services/sandboxImage/index.ts
466
+ const IMAGE_REPOSITORY = "doompi-sandbox";
467
+ const TAG_SAFE = /[^A-Za-z0-9_.-]/g;
468
+ /**
469
+ * Names one image build: a distribution version plus its image definition.
470
+ *
471
+ * The digest matters as much as the version. Editing the Dockerfile or the
472
+ * bridge without it would silently reuse a cached image that predates the
473
+ * change.
474
+ */
475
+ function formatImageTag(version, digest) {
476
+ return `${IMAGE_REPOSITORY}:v${version.replaceAll(TAG_SAFE, "-")}-${digest}`;
477
+ }
478
+ /**
479
+ * Self-contained Linux image for sandboxed sessions.
480
+ *
481
+ * nano is present because Pi falls back to it for the external editor when
482
+ * VISUAL and EDITOR are unset, which is always the case here: a host editor
483
+ * variable naming a desktop application would resolve to a binary the
484
+ * container does not have, so it is deliberately not forwarded.
485
+ *
486
+ * The distribution installs from the registry rather than mounting host
487
+ * modules: host installs carry platform-specific binaries a Linux container
488
+ * cannot run. DOOMPI_SANDBOX is baked in so nothing that reaches a shell in
489
+ * the container can present itself as an unsandboxed session.
490
+ */
491
+ function sandboxDockerfile() {
492
+ return [
493
+ "FROM node:22-bookworm-slim",
494
+ "RUN apt-get update \\",
495
+ " && apt-get install -y --no-install-recommends ca-certificates curl git nano openssh-client ripgrep \\",
496
+ " && rm -rf /var/lib/apt/lists/*",
497
+ "ARG DOOMPI_VERSION",
498
+ "RUN npm install -g @agimon-ai/doompi@${DOOMPI_VERSION}",
499
+ `COPY ${BRIDGE_FILE_NAME} ${BRIDGE_CONTAINER_PATH}`,
500
+ "# World-writable so the launch can map any host user id onto it.",
501
+ "RUN mkdir -m 0777 /doompi-home",
502
+ "ENV DOOMPI_SANDBOX=1 HOME=/doompi-home",
503
+ ""
504
+ ].join("\n");
505
+ }
506
+ /**
507
+ * Image for the cockpit container, which holds the hub rather than one session.
508
+ *
509
+ * Kept separate from the sandbox image rather than folded into it. The cockpit
510
+ * needs the web package and a tunnel client, and making every interactive
511
+ * `--sandbox` launch pay for an extra apt repository and a larger download to
512
+ * get them would be a poor trade.
513
+ *
514
+ * Installing `@agimon-ai/doompi-web` is enough for all three programs: its
515
+ * `@agimon-ai/doompi` dependency owns both the session server executable and
516
+ * the agent runtime.
517
+ */
518
+ function cockpitDockerfile() {
519
+ return [
520
+ "FROM node:22-bookworm-slim",
521
+ "RUN apt-get update \\",
522
+ " && apt-get install -y --no-install-recommends ca-certificates curl git gnupg nano openssh-client ripgrep \\",
523
+ " && rm -rf /var/lib/apt/lists/*",
524
+ "RUN curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \\",
525
+ " -o /usr/share/keyrings/cloudflare-main.gpg \\",
526
+ " && echo \"deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main\" \\",
527
+ " > /etc/apt/sources.list.d/cloudflared.list \\",
528
+ " && apt-get update && apt-get install -y --no-install-recommends cloudflared \\",
529
+ " && rm -rf /var/lib/apt/lists/*",
530
+ "ARG DOOMPI_VERSION",
531
+ "RUN npm install -g @agimon-ai/doompi-web@${DOOMPI_VERSION}",
532
+ "# World-writable so the launch can map any host user id onto it.",
533
+ "RUN mkdir -m 0777 /doompi-home",
534
+ "ENV DOOMPI_SANDBOX=1 HOME=/doompi-home",
535
+ ""
536
+ ].join("\n");
537
+ }
538
+ //#endregion
539
+ //#region src/services/sandboxImageTag/index.ts
540
+ const DIGEST_LENGTH = 8;
541
+ /** Tags the image with the distribution version and a digest of its definition. */
542
+ function sandboxImageTag(version) {
543
+ return formatImageTag(version, createHash("sha256").update(sandboxDockerfile()).update(sandboxBridgeSource()).digest("hex").slice(0, DIGEST_LENGTH));
544
+ }
545
+ /**
546
+ * Tags the cockpit image separately from the sandbox one.
547
+ *
548
+ * Same digest scheme, different definition, so editing either Dockerfile
549
+ * invalidates only its own image.
550
+ */
551
+ function cockpitImageTag(version) {
552
+ const digest = createHash("sha256").update(cockpitDockerfile()).digest("hex").slice(0, DIGEST_LENGTH);
553
+ return formatImageTag(`${version}-cockpit`, digest);
554
+ }
555
+ //#endregion
556
+ //#region src/services/harness/index.ts
557
+ const ENGINE_ENV = "DOOMPI_SANDBOX_ENGINE";
558
+ const BROKER_DISABLED_ENV = "DOOMPI_SANDBOX_BROKER";
559
+ const RUN_FLAGS_ENV = "DOOMPI_SANDBOX_RUN_FLAGS";
560
+ const DISABLED_VALUE = "0";
561
+ const ENGINES = [
562
+ "docker",
563
+ "podman",
564
+ "nerdctl",
565
+ "finch"
566
+ ];
567
+ const DOCKERFILE_NAME = "Dockerfile";
568
+ const MODES_FILE = path.join(".doom", "modes.yaml");
569
+ const LOCAL_PACKAGE_PATTERN = /^\s*(?:-|name:)\s*["']?\.{1,2}\//m;
570
+ const REPO_KEY_LENGTH = 12;
571
+ const require = createRequire(import.meta.url);
572
+ const SELF_MANIFEST = "@agimon-ai/doompi-sandbox/package.json";
573
+ function distributionVersion() {
574
+ return require(SELF_MANIFEST).version;
575
+ }
576
+ /** Fixed per-repository directory a reused dev container can keep mounted. */
577
+ function brokerDirectory(repoRoot) {
578
+ return path.join(os.tmpdir(), `${repoKey(repoRoot)}-broker`);
579
+ }
580
+ function repoKey(repoRoot) {
581
+ return `doompi-sandbox-${createHash("sha256").update(repoRoot).digest("hex").slice(0, REPO_KEY_LENGTH)}`;
582
+ }
583
+ async function detectEngine(runner, environment) {
584
+ const configured = environment[ENGINE_ENV]?.trim();
585
+ if (configured) {
586
+ if (ENGINES.includes(configured)) return configured;
587
+ throw new Error(`${ENGINE_ENV} must be one of: ${ENGINES.join(", ")}.`);
588
+ }
589
+ for (const engine of ENGINES) if ((await runner.capture(engine, ["--version"]))?.exitCode === 0) return engine;
590
+ throw new Error(`No container engine found. Install one of ${ENGINES.join(", ")}, or set ${ENGINE_ENV}.`);
591
+ }
592
+ /**
593
+ * Builds the image if the engine does not already have it.
594
+ *
595
+ * Takes its definition rather than assuming one, because the interactive
596
+ * sandbox and the cockpit container are two different images built the same
597
+ * way. The tag already carries a digest of the definition, so a changed
598
+ * Dockerfile misses the inspect and rebuilds.
599
+ */
600
+ async function ensureImage(runner, engine, version, onProgress, definition) {
601
+ const tag = definition.tag;
602
+ if ((await runner.capture(engine, [
603
+ "image",
604
+ "inspect",
605
+ tag
606
+ ]))?.exitCode === 0) return tag;
607
+ onProgress?.(`building ${tag} (first run for this DoomPi version)`);
608
+ const context = fs.mkdtempSync(path.join(os.tmpdir(), "doompi-sandbox-build-"));
609
+ try {
610
+ fs.writeFileSync(path.join(context, DOCKERFILE_NAME), definition.dockerfile);
611
+ for (const [name, contents] of Object.entries(definition.files ?? {})) fs.writeFileSync(path.join(context, name), contents);
612
+ const exitCode = await runner.run(engine, [
613
+ "build",
614
+ "-t",
615
+ tag,
616
+ "--build-arg",
617
+ `DOOMPI_VERSION=${version}`,
618
+ context
619
+ ]);
620
+ if (exitCode !== 0) throw new Error(`Building the ${tag} image failed with exit code ${exitCode}.`);
621
+ } finally {
622
+ fs.rmSync(context, {
623
+ recursive: true,
624
+ force: true
625
+ });
626
+ }
627
+ return tag;
628
+ }
629
+ function warnAboutLocalPackages(repoRoot, onProgress) {
630
+ let modes;
631
+ try {
632
+ modes = fs.readFileSync(path.join(repoRoot, MODES_FILE), "utf8");
633
+ } catch {
634
+ return;
635
+ }
636
+ if (LOCAL_PACKAGE_PATTERN.test(modes)) onProgress?.("modes.yaml declares local workspace packages; their platform-specific dependencies must be installed inside the container before they can load");
637
+ }
638
+ /** Builds the launcher with injectable seams for tests. */
639
+ function createSandboxLauncher(dependencies = {}) {
640
+ const runner = dependencies.runner ?? new SpawnEngineProcessRunner();
641
+ const beginBroker = dependencies.startBroker ?? startBroker;
642
+ const resolveLoginPorts = dependencies.loginPorts ?? availableLoginPorts;
643
+ return { async launchSandbox(request) {
644
+ const engine = await detectEngine(runner, request.environment);
645
+ const runFlags = parseRunFlags(request.environment[RUN_FLAGS_ENV]);
646
+ assertRunFlags(runFlags, RUN_FLAGS_ENV);
647
+ request.onProgress?.(`using ${engine}${runFlags.length > 0 ? ` with ${runFlags.join(" ")}` : ""}`);
648
+ const version = dependencies.version ?? distributionVersion();
649
+ const devcontainer = request.environment["DOOMPI_SANDBOX_DEVCONTAINER"] === DISABLED_VALUE ? void 0 : findDevcontainerConfig(request.repoRoot);
650
+ const imageTag = devcontainer ? void 0 : await ensureImage(runner, engine, version, request.onProgress, {
651
+ tag: sandboxImageTag(version),
652
+ dockerfile: sandboxDockerfile(),
653
+ files: { [BRIDGE_FILE_NAME]: sandboxBridgeSource() }
654
+ });
655
+ if (devcontainer) {
656
+ request.onProgress?.(`using the workspace dev container from ${path.relative(request.repoRoot, devcontainer)}`);
657
+ request.onProgress?.("its configuration owns the mounts and run arguments, so this is not an isolation boundary");
658
+ } else warnAboutLocalPackages(request.repoRoot, request.onProgress);
659
+ const broker = request.environment[BROKER_DISABLED_ENV] === DISABLED_VALUE ? void 0 : await beginBroker({
660
+ environment: request.environment,
661
+ onDenied: (reason) => request.onProgress?.(`broker refused a call: ${reason}`),
662
+ ...devcontainer ? { socketDirectory: brokerDirectory(request.repoRoot) } : {}
663
+ });
664
+ if (broker) request.onProgress?.(`brokering ${broker.providers.join(", ")}; provider keys stay on the host`);
665
+ const loginPorts = devcontainer ? [] : await resolveLoginPorts();
666
+ if (!devcontainer && loginPorts.length < OAUTH_CALLBACK_PORTS.length) request.onProgress?.("some OAuth callback ports are already taken; /login inside this sandbox may not complete");
667
+ try {
668
+ const hasTty = process.stdin.isTTY === true && process.stdout.isTTY === true;
669
+ if (devcontainer) return await runDevcontainerSession({
670
+ repoRoot: request.repoRoot,
671
+ cwd: request.cwd,
672
+ forwardArgs: request.forwardArgs,
673
+ environment: containerEnvironment(request.environment, broker),
674
+ engine,
675
+ runner,
676
+ version,
677
+ hasTty,
678
+ ...broker?.endpoint.transport === "unix" ? { socketDirectory: broker.endpoint.socketDirectory } : {},
679
+ ...request.onProgress ? { onProgress: request.onProgress } : {}
680
+ });
681
+ const host = {
682
+ hasTty,
683
+ platform: process.platform,
684
+ userId: process.getuid?.(),
685
+ groupId: process.getgid?.(),
686
+ repoKey: repoKey(request.repoRoot),
687
+ version,
688
+ ...dependencies.hostFacts
689
+ };
690
+ const plan = buildSandboxPlan({
691
+ repoRoot: request.repoRoot,
692
+ cwd: request.cwd,
693
+ forwardArgs: request.forwardArgs,
694
+ environment: request.environment,
695
+ engine,
696
+ host,
697
+ imageTag,
698
+ runFlags,
699
+ loginPorts,
700
+ broker
701
+ });
702
+ request.onProgress?.(`starting contained session in ${imageTag}`);
703
+ return await runner.run(engine, plan.runArgs);
704
+ } finally {
705
+ await broker?.stop();
706
+ }
707
+ } };
708
+ }
709
+ /** Contract entry the DoomPi harness resolves for `--sandbox`. */
710
+ function launchSandbox(request) {
711
+ return createSandboxLauncher().launchSandbox(request);
712
+ }
713
+ //#endregion
714
+ export { cockpitImageTag as a, parseRunFlags as c, DEVCONTAINER_DISABLED_ENV as d, launchSandbox as i, SpawnEngineProcessRunner as l, detectEngine as n, cockpitDockerfile as o, ensureImage as r, assertRunFlags as s, createSandboxLauncher as t, containerEnvironment as u };
715
+
716
+ //# sourceMappingURL=harness-CEuhOTr2.mjs.map