@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,298 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_harness = require("./harness-fK8CSaU1.cjs");
3
+ let node_child_process = require("node:child_process");
4
+ let _agimon_ai_doompi_core_sandboxHarness = require("@agimon-ai/doompi-core/sandboxHarness");
5
+ //#region src/services/cockpitPlan/index.ts
6
+ /**
7
+ * Projects the cockpit into one detached engine run.
8
+ *
9
+ * The sandbox plan next door describes a disposable container wrapped around a
10
+ * single interactive session. This one describes a long-lived container holding
11
+ * the hub and every session it goes on to spawn, which changes four things:
12
+ * it is detached rather than attached, it publishes a port rather than only
13
+ * consuming them, its home volume is scoped to the cockpit rather than to one
14
+ * repository, and it mounts a set of workspaces rather than a single repo.
15
+ */
16
+ const CONTAINER_HOME = "/doompi-home";
17
+ /**
18
+ * One volume for the cockpit, not one per repository.
19
+ *
20
+ * A cockpit spans several workspaces, so there is no repository to key on. This
21
+ * is also where `~/.doompi/web` lands, which is what makes paired passkeys and
22
+ * the bundle signing key survive a container restart. A rotated signing key
23
+ * would make every paired device refuse the cockpit, so the name must be
24
+ * stable.
25
+ */
26
+ const COCKPIT_HOME_VOLUME = "doompi-cockpit-home";
27
+ const COCKPIT_LABEL = "doompi.cockpit";
28
+ const COCKPIT_BINARY = "doompi-web";
29
+ const LOOPBACK = "127.0.0.1";
30
+ /**
31
+ * The cockpit binds every interface inside the container.
32
+ *
33
+ * A published port reaches the container's external interface, never its
34
+ * loopback, which is the same reason `oauthCallback.ts` sets its own bind
35
+ * address. The host side of the publish is pinned to loopback, so this is not
36
+ * exposed beyond the machine.
37
+ */
38
+ const CONTAINER_BIND = "0.0.0.0";
39
+ const LINUX_PLATFORM = "linux";
40
+ const DISABLED = "0";
41
+ /**
42
+ * Git identity, passed as environment rather than by mounting a config.
43
+ *
44
+ * The agent can commit; it cannot push, because no key crosses the boundary.
45
+ * Mounting `~/.gitconfig` would drag in whatever else it configures, and
46
+ * forwarding an ssh agent would hand the container a live credential that
47
+ * authenticates as the user everywhere their key reaches.
48
+ */
49
+ function gitIdentityEnvironment(identity) {
50
+ if (identity === void 0) return {};
51
+ return {
52
+ GIT_AUTHOR_NAME: identity.name,
53
+ GIT_AUTHOR_EMAIL: identity.email,
54
+ GIT_COMMITTER_NAME: identity.name,
55
+ GIT_COMMITTER_EMAIL: identity.email
56
+ };
57
+ }
58
+ function buildCockpitPlan(input) {
59
+ const { engine, host, broker, imageTag, port } = input;
60
+ if (input.workspaces.length === 0) throw new Error("A cockpit container needs at least one workspace to mount.");
61
+ const environment = {
62
+ ...require_harness.containerEnvironment(input.environment, broker),
63
+ ...gitIdentityEnvironment(input.gitIdentity),
64
+ [require_harness.DEVCONTAINER_DISABLED_ENV]: DISABLED,
65
+ [_agimon_ai_doompi_core_sandboxHarness.DOOMPI_SANDBOX_ENV]: "1"
66
+ };
67
+ const environmentPairs = Object.entries(environment).sort(([left], [right]) => left.localeCompare(right));
68
+ return {
69
+ imageTag,
70
+ runArgs: [
71
+ "run",
72
+ "-d",
73
+ "--label",
74
+ `${COCKPIT_LABEL}=${String(port)}`,
75
+ "-p",
76
+ `${LOOPBACK}:${String(port)}:${String(port)}`,
77
+ ...input.workspaces.flatMap((workspace) => ["-v", `${workspace.path}:${workspace.path}`]),
78
+ "-v",
79
+ `${COCKPIT_HOME_VOLUME}:${CONTAINER_HOME}`,
80
+ ...broker?.endpoint.transport === "tcp" ? ["--add-host", `host.docker.internal:host-gateway`] : [],
81
+ ...broker?.endpoint.transport === "unix" ? ["-v", `${broker.endpoint.socketDirectory}:/run/doompi`] : [],
82
+ "-w",
83
+ input.workspaces[0]?.path ?? CONTAINER_HOME,
84
+ "-e",
85
+ `HOME=${CONTAINER_HOME}`,
86
+ ...environmentPairs.flatMap(([name, value]) => ["-e", `${name}=${value}`]),
87
+ ...host.platform === LINUX_PLATFORM && host.userId !== void 0 ? [
88
+ "--user",
89
+ `${String(host.userId)}:${String(host.groupId ?? host.userId)}`,
90
+ ...engine === "podman" ? ["--userns=keep-id"] : []
91
+ ] : [],
92
+ ...input.runFlags ?? [],
93
+ imageTag,
94
+ COCKPIT_BINARY,
95
+ "--host",
96
+ CONTAINER_BIND,
97
+ "--port",
98
+ String(port)
99
+ ]
100
+ };
101
+ }
102
+ //#endregion
103
+ //#region src/services/cockpitHarness/index.ts
104
+ /**
105
+ * Runs the cockpit itself in a container, detached, holding the hub and every
106
+ * session it goes on to spawn.
107
+ *
108
+ * The interactive harness next door wraps one session and blocks until it
109
+ * exits. This one returns a handle while the container keeps running, which is
110
+ * why it is a separate entry rather than a flag on the other.
111
+ */
112
+ const RUN_FLAGS_ENV = "DOOMPI_SANDBOX_RUN_FLAGS";
113
+ const READY_TIMEOUT_MS = 6e4;
114
+ const READY_POLL_MS = 250;
115
+ const HUB_ROLE = "hub";
116
+ /**
117
+ * Reads the host's committer identity.
118
+ *
119
+ * Passed into the container as environment rather than by mounting the config,
120
+ * so the agent can commit without any credential crossing the boundary. It
121
+ * cannot push, and that is the intended limit.
122
+ */
123
+ function readGitIdentity() {
124
+ const read = (key) => {
125
+ try {
126
+ const value = (0, node_child_process.execFileSync)("git", [
127
+ "config",
128
+ "--global",
129
+ "--get",
130
+ key
131
+ ], { encoding: "utf8" }).trim();
132
+ return value === "" ? void 0 : value;
133
+ } catch {
134
+ return;
135
+ }
136
+ };
137
+ const name = read("user.name");
138
+ const email = read("user.email");
139
+ return name !== void 0 && email !== void 0 ? {
140
+ name,
141
+ email
142
+ } : void 0;
143
+ }
144
+ /** Answers whether the hub inside the container is serving yet. */
145
+ async function defaultProbe(port) {
146
+ try {
147
+ const response = await fetch(`http://127.0.0.1:${String(port)}/api/health`);
148
+ if (!response.ok) return false;
149
+ return (await response.json()).role === HUB_ROLE;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ async function stopContainer(runner, engine, id) {
155
+ const stopped = await runner.capture(engine, ["stop", id]);
156
+ await runner.capture(engine, [
157
+ "rm",
158
+ "-f",
159
+ id
160
+ ]);
161
+ return stopped?.exitCode === 0;
162
+ }
163
+ /**
164
+ * Whether the engine still reports this container as running.
165
+ *
166
+ * A removed container makes `inspect` fail rather than print `false`, so a
167
+ * non-zero exit is read the same way: not running.
168
+ */
169
+ async function isRunning(runner, engine, id) {
170
+ const probed = await runner.capture(engine, [
171
+ "inspect",
172
+ "-f",
173
+ "{{.State.Running}}",
174
+ id
175
+ ]);
176
+ return probed?.exitCode === 0 && probed.stdout.trim() === "true";
177
+ }
178
+ function createCockpitHarness(dependencies = {}) {
179
+ const runner = dependencies.runner ?? new require_harness.SpawnEngineProcessRunner();
180
+ const probe = dependencies.probe ?? defaultProbe;
181
+ const readIdentity = dependencies.gitIdentity ?? readGitIdentity;
182
+ const now = dependencies.now ?? (() => Date.now());
183
+ return {
184
+ async startCockpitContainer(request) {
185
+ if (request.workspaces.length === 0) return {
186
+ ok: false,
187
+ error: "A cockpit container needs at least one workspace to mount."
188
+ };
189
+ let engine;
190
+ try {
191
+ engine = await require_harness.detectEngine(runner, request.environment);
192
+ } catch (error) {
193
+ return {
194
+ ok: false,
195
+ error: error instanceof Error ? error.message : String(error)
196
+ };
197
+ }
198
+ const runFlags = require_harness.parseRunFlags(request.environment[RUN_FLAGS_ENV]);
199
+ try {
200
+ require_harness.assertRunFlags(runFlags, RUN_FLAGS_ENV);
201
+ } catch (error) {
202
+ return {
203
+ ok: false,
204
+ error: error instanceof Error ? error.message : String(error)
205
+ };
206
+ }
207
+ request.onProgress?.(`using ${engine}${runFlags.length > 0 ? ` with ${runFlags.join(" ")}` : ""}`);
208
+ const version = dependencies.version ?? "0.0.0";
209
+ let imageTag;
210
+ try {
211
+ imageTag = await require_harness.ensureImage(runner, engine, version, request.onProgress, {
212
+ tag: require_harness.cockpitImageTag(version),
213
+ dockerfile: require_harness.cockpitDockerfile()
214
+ });
215
+ } catch (error) {
216
+ return {
217
+ ok: false,
218
+ error: error instanceof Error ? error.message : String(error)
219
+ };
220
+ }
221
+ const plan = buildCockpitPlan({
222
+ workspaces: request.workspaces,
223
+ port: request.port,
224
+ environment: request.environment,
225
+ engine,
226
+ host: {
227
+ hasTty: false,
228
+ platform: process.platform,
229
+ repoKey: "",
230
+ version
231
+ },
232
+ imageTag,
233
+ runFlags,
234
+ ...readIdentity() === void 0 ? {} : { gitIdentity: readIdentity() }
235
+ });
236
+ const started = await runner.capture(engine, plan.runArgs);
237
+ const containerId = started?.stdout.trim().split("\n").pop() ?? "";
238
+ if (started?.exitCode !== 0 || containerId === "") return {
239
+ ok: false,
240
+ error: `The cockpit container did not start: ${started?.stdout.trim() ?? "no output"}`
241
+ };
242
+ request.onProgress?.("waiting for the cockpit to answer");
243
+ const deadline = now() + READY_TIMEOUT_MS;
244
+ for (;;) {
245
+ if (await probe(request.port)) break;
246
+ if (now() >= deadline) {
247
+ const logs = await runner.capture(engine, [
248
+ "logs",
249
+ "--tail",
250
+ "20",
251
+ containerId
252
+ ]);
253
+ await stopContainer(runner, engine, containerId);
254
+ return {
255
+ ok: false,
256
+ error: `The cockpit did not answer within ${String(READY_TIMEOUT_MS / 1e3)}s.\n${logs?.stdout ?? ""}`.trim()
257
+ };
258
+ }
259
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
260
+ }
261
+ return {
262
+ ok: true,
263
+ handle: {
264
+ containerId,
265
+ stop: async () => {
266
+ await stopContainer(runner, engine, containerId);
267
+ },
268
+ alive: async () => await isRunning(runner, engine, containerId)
269
+ }
270
+ };
271
+ },
272
+ async reapCockpitContainer(containerId) {
273
+ for (const engine of [
274
+ "docker",
275
+ "podman",
276
+ "nerdctl",
277
+ "finch"
278
+ ]) {
279
+ if ((await runner.capture(engine, ["--version"]))?.exitCode !== 0) continue;
280
+ return await stopContainer(runner, engine, containerId);
281
+ }
282
+ return false;
283
+ }
284
+ };
285
+ }
286
+ /** The module-shaped entry a host resolves from the composition. */
287
+ async function startCockpitContainer(request) {
288
+ return await createCockpitHarness().startCockpitContainer(request);
289
+ }
290
+ async function reapCockpitContainer(containerId) {
291
+ return await createCockpitHarness().reapCockpitContainer(containerId);
292
+ }
293
+ //#endregion
294
+ exports.createCockpitHarness = createCockpitHarness;
295
+ exports.reapCockpitContainer = reapCockpitContainer;
296
+ exports.startCockpitContainer = startCockpitContainer;
297
+
298
+ //# sourceMappingURL=cockpitHarness.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cockpitHarness.cjs","names":["containerEnvironment","DEVCONTAINER_DISABLED_ENV","DOOMPI_SANDBOX_ENV","execFileSync","SpawnEngineProcessRunner","detectEngine","parseRunFlags","ensureImage","cockpitImageTag","cockpitDockerfile"],"sources":["../src/services/cockpitPlan/index.ts","../src/services/cockpitHarness/index.ts"],"sourcesContent":["import type { CockpitWorkspace } from '@agimon-ai/doompi-core/cockpitContainer';\nimport { DOOMPI_SANDBOX_ENV } from '@agimon-ai/doompi-core/sandboxHarness';\n\nimport type { SandboxEngine, SandboxHostFacts } from '../../types/sandboxHarness';\nimport { DEVCONTAINER_DISABLED_ENV } from '../devcontainer';\nimport { containerEnvironment, type SandboxPlanBroker } from '../sandboxPlan';\n\n/**\n * Projects the cockpit into one detached engine run.\n *\n * The sandbox plan next door describes a disposable container wrapped around a\n * single interactive session. This one describes a long-lived container holding\n * the hub and every session it goes on to spawn, which changes four things:\n * it is detached rather than attached, it publishes a port rather than only\n * consuming them, its home volume is scoped to the cockpit rather than to one\n * repository, and it mounts a set of workspaces rather than a single repo.\n */\n\nconst CONTAINER_HOME = '/doompi-home';\n/**\n * One volume for the cockpit, not one per repository.\n *\n * A cockpit spans several workspaces, so there is no repository to key on. This\n * is also where `~/.doompi/web` lands, which is what makes paired passkeys and\n * the bundle signing key survive a container restart. A rotated signing key\n * would make every paired device refuse the cockpit, so the name must be\n * stable.\n */\nexport const COCKPIT_HOME_VOLUME = 'doompi-cockpit-home';\nexport const COCKPIT_LABEL = 'doompi.cockpit';\nconst COCKPIT_BINARY = 'doompi-web';\nconst LOOPBACK = '127.0.0.1';\n/**\n * The cockpit binds every interface inside the container.\n *\n * A published port reaches the container's external interface, never its\n * loopback, which is the same reason `oauthCallback.ts` sets its own bind\n * address. The host side of the publish is pinned to loopback, so this is not\n * exposed beyond the machine.\n */\nconst CONTAINER_BIND = '0.0.0.0';\nconst LINUX_PLATFORM = 'linux';\nconst DISABLED = '0';\n\n/** Git identity the agent commits under; no key crosses the boundary, so it cannot push. */\nexport interface CockpitGitIdentity {\n name: string;\n email: string;\n}\n\nexport interface CockpitPlanInput {\n workspaces: readonly CockpitWorkspace[];\n /** Published on the host loopback and bound inside the container. */\n port: number;\n environment: Readonly<Record<string, string | undefined>>;\n engine: SandboxEngine;\n host: SandboxHostFacts;\n imageTag: string;\n runFlags?: readonly string[];\n broker?: SandboxPlanBroker;\n gitIdentity?: CockpitGitIdentity;\n}\n\nexport interface CockpitPlan {\n imageTag: string;\n /** Full engine argv after the engine binary itself. */\n runArgs: string[];\n}\n\n/**\n * Git identity, passed as environment rather than by mounting a config.\n *\n * The agent can commit; it cannot push, because no key crosses the boundary.\n * Mounting `~/.gitconfig` would drag in whatever else it configures, and\n * forwarding an ssh agent would hand the container a live credential that\n * authenticates as the user everywhere their key reaches.\n */\nfunction gitIdentityEnvironment(identity: CockpitGitIdentity | undefined): Record<string, string> {\n if (identity === undefined) return {};\n return {\n GIT_AUTHOR_NAME: identity.name,\n GIT_AUTHOR_EMAIL: identity.email,\n GIT_COMMITTER_NAME: identity.name,\n GIT_COMMITTER_EMAIL: identity.email,\n };\n}\n\nexport function buildCockpitPlan(input: CockpitPlanInput): CockpitPlan {\n const { engine, host, broker, imageTag, port } = input;\n if (input.workspaces.length === 0) throw new Error('A cockpit container needs at least one workspace to mount.');\n\n const environment: Record<string, string> = {\n ...containerEnvironment(input.environment, broker),\n ...gitIdentityEnvironment(input.gitIdentity),\n // A workspace carrying a devcontainer config would otherwise replace the\n // whole plan with an author-controlled one that can mount anything,\n // including the docker socket. That would make the containment claim false.\n [DEVCONTAINER_DISABLED_ENV]: DISABLED,\n [DOOMPI_SANDBOX_ENV]: '1',\n };\n const environmentPairs = Object.entries(environment).sort(([left], [right]) => left.localeCompare(right));\n\n const runArgs = [\n 'run',\n // Detached, and deliberately not --rm: a container that exits on startup\n // has to survive long enough for its logs to be read. The stop path and the\n // reaper both remove it.\n '-d',\n '--label',\n `${COCKPIT_LABEL}=${String(port)}`,\n '-p',\n `${LOOPBACK}:${String(port)}:${String(port)}`,\n ...input.workspaces.flatMap((workspace) => ['-v', `${workspace.path}:${workspace.path}`]),\n '-v',\n `${COCKPIT_HOME_VOLUME}:${CONTAINER_HOME}`,\n ...(broker?.endpoint.transport === 'tcp' ? ['--add-host', `host.docker.internal:host-gateway`] : []),\n ...(broker?.endpoint.transport === 'unix' ? ['-v', `${broker.endpoint.socketDirectory}:/run/doompi`] : []),\n '-w',\n input.workspaces[0]?.path ?? CONTAINER_HOME,\n '-e',\n `HOME=${CONTAINER_HOME}`,\n ...environmentPairs.flatMap(([name, value]) => ['-e', `${name}=${value}`]),\n ...(host.platform === LINUX_PLATFORM && host.userId !== undefined\n ? [\n '--user',\n `${String(host.userId)}:${String(host.groupId ?? host.userId)}`,\n ...(engine === 'podman' ? ['--userns=keep-id'] : []),\n ]\n : []),\n // Last before the image so a configured option wins over the defaults above\n // it, and cannot be mistaken for the image or its command.\n ...(input.runFlags ?? []),\n imageTag,\n COCKPIT_BINARY,\n '--host',\n CONTAINER_BIND,\n '--port',\n String(port),\n ];\n\n return { imageTag, runArgs };\n}\n","import { execFileSync } from 'node:child_process';\n\nimport type {\n CockpitContainerHandle,\n CockpitContainerHarnessModule,\n CockpitContainerRequest,\n CockpitContainerStart,\n} from '@agimon-ai/doompi-core/cockpitContainer';\n\nimport type { EngineProcessRunner, SandboxEngine } from '../../types/sandboxHarness';\nimport { buildCockpitPlan, type CockpitGitIdentity } from '../cockpitPlan';\nimport { SpawnEngineProcessRunner } from '../engineProcess';\nimport { detectEngine, ensureImage } from '../harness';\nimport { parseRunFlags, assertRunFlags } from '../runFlags';\nimport { cockpitDockerfile } from '../sandboxImage';\nimport { cockpitImageTag } from '../sandboxImageTag';\n\n/**\n * Runs the cockpit itself in a container, detached, holding the hub and every\n * session it goes on to spawn.\n *\n * The interactive harness next door wraps one session and blocks until it\n * exits. This one returns a handle while the container keeps running, which is\n * why it is a separate entry rather than a flag on the other.\n */\n\nconst RUN_FLAGS_ENV = 'DOOMPI_SANDBOX_RUN_FLAGS';\nconst READY_TIMEOUT_MS = 60_000;\nconst READY_POLL_MS = 250;\nconst HUB_ROLE = 'hub';\n\nexport interface CockpitHarnessDependencies {\n runner?: EngineProcessRunner;\n version?: string;\n /** Test seam: reports whether the cockpit inside is answering yet. */\n probe?: (port: number) => Promise<boolean>;\n /** Test seam: the host's git identity, which is read from the real config by default. */\n gitIdentity?: () => CockpitGitIdentity | undefined;\n now?: () => number;\n}\n\n/**\n * Reads the host's committer identity.\n *\n * Passed into the container as environment rather than by mounting the config,\n * so the agent can commit without any credential crossing the boundary. It\n * cannot push, and that is the intended limit.\n */\nfunction readGitIdentity(): CockpitGitIdentity | undefined {\n const read = (key: string): string | undefined => {\n try {\n const value = execFileSync('git', ['config', '--global', '--get', key], { encoding: 'utf8' }).trim();\n return value === '' ? undefined : value;\n } catch {\n // No git, or no such key. An agent that cannot commit is a smaller\n // problem than a launch that refuses to start.\n return undefined;\n }\n };\n const name = read('user.name');\n const email = read('user.email');\n return name !== undefined && email !== undefined ? { name, email } : undefined;\n}\n\n/** Answers whether the hub inside the container is serving yet. */\nasync function defaultProbe(port: number): Promise<boolean> {\n try {\n const response = await fetch(`http://127.0.0.1:${String(port)}/api/health`);\n if (!response.ok) return false;\n return ((await response.json()) as { role?: unknown }).role === HUB_ROLE;\n } catch {\n return false;\n }\n}\n\nasync function stopContainer(runner: EngineProcessRunner, engine: SandboxEngine, id: string): Promise<boolean> {\n const stopped = await runner.capture(engine, ['stop', id]);\n // Removed explicitly because the run is not --rm: a container that dies on\n // startup has to survive long enough for its logs to be read.\n await runner.capture(engine, ['rm', '-f', id]);\n return stopped?.exitCode === 0;\n}\n\n/**\n * Whether the engine still reports this container as running.\n *\n * A removed container makes `inspect` fail rather than print `false`, so a\n * non-zero exit is read the same way: not running.\n */\nasync function isRunning(runner: EngineProcessRunner, engine: SandboxEngine, id: string): Promise<boolean> {\n const probed = await runner.capture(engine, ['inspect', '-f', '{{.State.Running}}', id]);\n return probed?.exitCode === 0 && probed.stdout.trim() === 'true';\n}\n\nexport function createCockpitHarness(dependencies: CockpitHarnessDependencies = {}): CockpitContainerHarnessModule {\n const runner = dependencies.runner ?? new SpawnEngineProcessRunner();\n const probe = dependencies.probe ?? defaultProbe;\n const readIdentity = dependencies.gitIdentity ?? readGitIdentity;\n const now = dependencies.now ?? ((): number => Date.now());\n\n return {\n async startCockpitContainer(request: CockpitContainerRequest): Promise<CockpitContainerStart> {\n if (request.workspaces.length === 0) {\n return { ok: false, error: 'A cockpit container needs at least one workspace to mount.' };\n }\n let engine: SandboxEngine;\n try {\n engine = await detectEngine(runner, request.environment);\n } catch (error) {\n return { ok: false, error: error instanceof Error ? error.message : String(error) };\n }\n const runFlags = parseRunFlags(request.environment[RUN_FLAGS_ENV]);\n try {\n assertRunFlags(runFlags, RUN_FLAGS_ENV);\n } catch (error) {\n return { ok: false, error: error instanceof Error ? error.message : String(error) };\n }\n request.onProgress?.(`using ${engine}${runFlags.length > 0 ? ` with ${runFlags.join(' ')}` : ''}`);\n\n const version = dependencies.version ?? '0.0.0';\n let imageTag: string;\n try {\n // Before the tunnel is reported ready, never inside a session spawn:\n // a cold build is minutes and the hub gives a spawning session ten\n // seconds to register.\n imageTag = await ensureImage(runner, engine, version, request.onProgress, {\n tag: cockpitImageTag(version),\n dockerfile: cockpitDockerfile(),\n });\n } catch (error) {\n return { ok: false, error: error instanceof Error ? error.message : String(error) };\n }\n\n const plan = buildCockpitPlan({\n workspaces: request.workspaces,\n port: request.port,\n environment: request.environment,\n engine,\n host: { hasTty: false, platform: process.platform, repoKey: '', version },\n imageTag,\n runFlags,\n ...(readIdentity() === undefined ? {} : { gitIdentity: readIdentity() as CockpitGitIdentity }),\n });\n\n const started = await runner.capture(engine, plan.runArgs);\n const containerId = started?.stdout.trim().split('\\n').pop() ?? '';\n if (started?.exitCode !== 0 || containerId === '') {\n return { ok: false, error: `The cockpit container did not start: ${started?.stdout.trim() ?? 'no output'}` };\n }\n request.onProgress?.('waiting for the cockpit to answer');\n\n const deadline = now() + READY_TIMEOUT_MS;\n for (;;) {\n if (await probe(request.port)) break;\n if (now() >= deadline) {\n const logs = await runner.capture(engine, ['logs', '--tail', '20', containerId]);\n await stopContainer(runner, engine, containerId);\n return {\n ok: false,\n error:\n `The cockpit did not answer within ${String(READY_TIMEOUT_MS / 1000)}s.\\n${logs?.stdout ?? ''}`.trim(),\n };\n }\n await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));\n }\n\n const handle: CockpitContainerHandle = {\n containerId,\n stop: async () => {\n await stopContainer(runner, engine, containerId);\n },\n alive: async () => await isRunning(runner, engine, containerId),\n };\n return { ok: true, handle };\n },\n\n async reapCockpitContainer(containerId: string): Promise<boolean> {\n // Named by id rather than searched for, because the caller recorded it\n // before the process that owned it went away.\n for (const engine of ['docker', 'podman', 'nerdctl', 'finch'] as const) {\n const probed = await runner.capture(engine, ['--version']);\n if (probed?.exitCode !== 0) continue;\n return await stopContainer(runner, engine, containerId);\n }\n return false;\n },\n };\n}\n\n/** The module-shaped entry a host resolves from the composition. */\nexport async function startCockpitContainer(request: CockpitContainerRequest): Promise<CockpitContainerStart> {\n return await createCockpitHarness().startCockpitContainer(request);\n}\n\nexport async function reapCockpitContainer(containerId: string): Promise<boolean> {\n return await createCockpitHarness().reapCockpitContainer(containerId);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkBA,MAAM,iBAAiB;;;;;;;;;;AAUvB,MAAa,sBAAsB;AACnC,MAAa,gBAAgB;AAC7B,MAAM,iBAAiB;AACvB,MAAM,WAAW;;;;;;;;;AASjB,MAAM,iBAAiB;AACvB,MAAM,iBAAiB;AACvB,MAAM,WAAW;;;;;;;;;AAmCjB,SAAS,uBAAuB,UAAkE;CAChG,IAAI,aAAa,KAAA,GAAW,OAAO,CAAC;CACpC,OAAO;EACL,iBAAiB,SAAS;EAC1B,kBAAkB,SAAS;EAC3B,oBAAoB,SAAS;EAC7B,qBAAqB,SAAS;CAChC;AACF;AAEA,SAAgB,iBAAiB,OAAsC;CACrE,MAAM,EAAE,QAAQ,MAAM,QAAQ,UAAU,SAAS;CACjD,IAAI,MAAM,WAAW,WAAW,GAAG,MAAM,IAAI,MAAM,4DAA4D;CAE/G,MAAM,cAAsC;EAC1C,GAAGA,gBAAAA,qBAAqB,MAAM,aAAa,MAAM;EACjD,GAAG,uBAAuB,MAAM,WAAW;GAI1CC,gBAAAA,4BAA4B;GAC5BC,sCAAAA,qBAAqB;CACxB;CACA,MAAM,mBAAmB,OAAO,QAAQ,WAAW,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,WAAW,KAAK,cAAc,KAAK,CAAC;CAwCxG,OAAO;EAAE;EAAU,SAAA;GArCjB;GAIA;GACA;GACA,GAAG,cAAc,GAAG,OAAO,IAAI;GAC/B;GACA,GAAG,SAAS,GAAG,OAAO,IAAI,EAAE,GAAG,OAAO,IAAI;GAC1C,GAAG,MAAM,WAAW,SAAS,cAAc,CAAC,MAAM,GAAG,UAAU,KAAK,GAAG,UAAU,MAAM,CAAC;GACxF;GACA,GAAG,oBAAoB,GAAG;GAC1B,GAAI,QAAQ,SAAS,cAAc,QAAQ,CAAC,cAAc,mCAAmC,IAAI,CAAC;GAClG,GAAI,QAAQ,SAAS,cAAc,SAAS,CAAC,MAAM,GAAG,OAAO,SAAS,gBAAgB,aAAa,IAAI,CAAC;GACxG;GACA,MAAM,WAAW,EAAE,EAAE,QAAQ;GAC7B;GACA,QAAQ;GACR,GAAG,iBAAiB,SAAS,CAAC,MAAM,WAAW,CAAC,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;GACzE,GAAI,KAAK,aAAa,kBAAkB,KAAK,WAAW,KAAA,IACpD;IACE;IACA,GAAG,OAAO,KAAK,MAAM,EAAE,GAAG,OAAO,KAAK,WAAW,KAAK,MAAM;IAC5D,GAAI,WAAW,WAAW,CAAC,kBAAkB,IAAI,CAAC;GACpD,IACA,CAAC;GAGL,GAAI,MAAM,YAAY,CAAC;GACvB;GACA;GACA;GACA;GACA;GACA,OAAO,IAAI;EAGY;CAAE;AAC7B;;;;;;;;;;;ACnHA,MAAM,gBAAgB;AACtB,MAAM,mBAAmB;AACzB,MAAM,gBAAgB;AACtB,MAAM,WAAW;;;;;;;;AAmBjB,SAAS,kBAAkD;CACzD,MAAM,QAAQ,QAAoC;EAChD,IAAI;GACF,MAAM,SAAA,GAAQC,mBAAAA,aAAAA,CAAa,OAAO;IAAC;IAAU;IAAY;IAAS;GAAG,GAAG,EAAE,UAAU,OAAO,CAAC,CAAC,CAAC,KAAK;GACnG,OAAO,UAAU,KAAK,KAAA,IAAY;EACpC,QAAQ;GAGN;EACF;CACF;CACA,MAAM,OAAO,KAAK,WAAW;CAC7B,MAAM,QAAQ,KAAK,YAAY;CAC/B,OAAO,SAAS,KAAA,KAAa,UAAU,KAAA,IAAY;EAAE;EAAM;CAAM,IAAI,KAAA;AACvE;;AAGA,eAAe,aAAa,MAAgC;CAC1D,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,oBAAoB,OAAO,IAAI,EAAE,YAAY;EAC1E,IAAI,CAAC,SAAS,IAAI,OAAO;EACzB,QAAS,MAAM,SAAS,KAAK,EAAA,CAA0B,SAAS;CAClE,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,cAAc,QAA6B,QAAuB,IAA8B;CAC7G,MAAM,UAAU,MAAM,OAAO,QAAQ,QAAQ,CAAC,QAAQ,EAAE,CAAC;CAGzD,MAAM,OAAO,QAAQ,QAAQ;EAAC;EAAM;EAAM;CAAE,CAAC;CAC7C,OAAO,SAAS,aAAa;AAC/B;;;;;;;AAQA,eAAe,UAAU,QAA6B,QAAuB,IAA8B;CACzG,MAAM,SAAS,MAAM,OAAO,QAAQ,QAAQ;EAAC;EAAW;EAAM;EAAsB;CAAE,CAAC;CACvF,OAAO,QAAQ,aAAa,KAAK,OAAO,OAAO,KAAK,MAAM;AAC5D;AAEA,SAAgB,qBAAqB,eAA2C,CAAC,GAAkC;CACjH,MAAM,SAAS,aAAa,UAAU,IAAIC,gBAAAA,yBAAyB;CACnE,MAAM,QAAQ,aAAa,SAAS;CACpC,MAAM,eAAe,aAAa,eAAe;CACjD,MAAM,MAAM,aAAa,cAAsB,KAAK,IAAI;CAExD,OAAO;EACL,MAAM,sBAAsB,SAAkE;GAC5F,IAAI,QAAQ,WAAW,WAAW,GAChC,OAAO;IAAE,IAAI;IAAO,OAAO;GAA6D;GAE1F,IAAI;GACJ,IAAI;IACF,SAAS,MAAMC,gBAAAA,aAAa,QAAQ,QAAQ,WAAW;GACzD,SAAS,OAAO;IACd,OAAO;KAAE,IAAI;KAAO,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAAE;GACpF;GACA,MAAM,WAAWC,gBAAAA,cAAc,QAAQ,YAAY,cAAc;GACjE,IAAI;IACF,gBAAA,eAAe,UAAU,aAAa;GACxC,SAAS,OAAO;IACd,OAAO;KAAE,IAAI;KAAO,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAAE;GACpF;GACA,QAAQ,aAAa,SAAS,SAAS,SAAS,SAAS,IAAI,SAAS,SAAS,KAAK,GAAG,MAAM,IAAI;GAEjG,MAAM,UAAU,aAAa,WAAW;GACxC,IAAI;GACJ,IAAI;IAIF,WAAW,MAAMC,gBAAAA,YAAY,QAAQ,QAAQ,SAAS,QAAQ,YAAY;KACxE,KAAKC,gBAAAA,gBAAgB,OAAO;KAC5B,YAAYC,gBAAAA,kBAAkB;IAChC,CAAC;GACH,SAAS,OAAO;IACd,OAAO;KAAE,IAAI;KAAO,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAAE;GACpF;GAEA,MAAM,OAAO,iBAAiB;IAC5B,YAAY,QAAQ;IACpB,MAAM,QAAQ;IACd,aAAa,QAAQ;IACrB;IACA,MAAM;KAAE,QAAQ;KAAO,UAAU,QAAQ;KAAU,SAAS;KAAI;IAAQ;IACxE;IACA;IACA,GAAI,aAAa,MAAM,KAAA,IAAY,CAAC,IAAI,EAAE,aAAa,aAAa,EAAwB;GAC9F,CAAC;GAED,MAAM,UAAU,MAAM,OAAO,QAAQ,QAAQ,KAAK,OAAO;GACzD,MAAM,cAAc,SAAS,OAAO,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,KAAK;GAChE,IAAI,SAAS,aAAa,KAAK,gBAAgB,IAC7C,OAAO;IAAE,IAAI;IAAO,OAAO,wCAAwC,SAAS,OAAO,KAAK,KAAK;GAAc;GAE7G,QAAQ,aAAa,mCAAmC;GAExD,MAAM,WAAW,IAAI,IAAI;GACzB,SAAS;IACP,IAAI,MAAM,MAAM,QAAQ,IAAI,GAAG;IAC/B,IAAI,IAAI,KAAK,UAAU;KACrB,MAAM,OAAO,MAAM,OAAO,QAAQ,QAAQ;MAAC;MAAQ;MAAU;MAAM;KAAW,CAAC;KAC/E,MAAM,cAAc,QAAQ,QAAQ,WAAW;KAC/C,OAAO;MACL,IAAI;MACJ,OACE,qCAAqC,OAAO,mBAAmB,GAAI,EAAE,MAAM,MAAM,UAAU,KAAK,KAAK;KACzG;IACF;IACA,MAAM,IAAI,SAAS,YAAY,WAAW,SAAS,aAAa,CAAC;GACnE;GASA,OAAO;IAAE,IAAI;IAAM,QAAA;KANjB;KACA,MAAM,YAAY;MAChB,MAAM,cAAc,QAAQ,QAAQ,WAAW;KACjD;KACA,OAAO,YAAY,MAAM,UAAU,QAAQ,QAAQ,WAAW;IAExC;GAAE;EAC5B;EAEA,MAAM,qBAAqB,aAAuC;GAGhE,KAAK,MAAM,UAAU;IAAC;IAAU;IAAU;IAAW;GAAO,GAAY;IAEtE,KAAI,MADiB,OAAO,QAAQ,QAAQ,CAAC,WAAW,CAAC,EAAA,EAC7C,aAAa,GAAG;IAC5B,OAAO,MAAM,cAAc,QAAQ,QAAQ,WAAW;GACxD;GACA,OAAO;EACT;CACF;AACF;;AAGA,eAAsB,sBAAsB,SAAkE;CAC5G,OAAO,MAAM,qBAAqB,CAAC,CAAC,sBAAsB,OAAO;AACnE;AAEA,eAAsB,qBAAqB,aAAuC;CAChF,OAAO,MAAM,qBAAqB,CAAC,CAAC,qBAAqB,WAAW;AACtE"}
@@ -0,0 +1,26 @@
1
+ import { n as EngineProcessRunner } from "./sandboxHarness-BIvuRMio.mjs";
2
+ import { CockpitContainerHarnessModule, CockpitContainerRequest, CockpitContainerStart } from "@agimon-ai/doompi-core/cockpitContainer";
3
+ //#region src/services/cockpitPlan/index.d.ts
4
+ /** Git identity the agent commits under; no key crosses the boundary, so it cannot push. */
5
+ interface CockpitGitIdentity {
6
+ name: string;
7
+ email: string;
8
+ }
9
+ //#endregion
10
+ //#region src/services/cockpitHarness/index.d.ts
11
+ interface CockpitHarnessDependencies {
12
+ runner?: EngineProcessRunner;
13
+ version?: string;
14
+ /** Test seam: reports whether the cockpit inside is answering yet. */
15
+ probe?: (port: number) => Promise<boolean>;
16
+ /** Test seam: the host's git identity, which is read from the real config by default. */
17
+ gitIdentity?: () => CockpitGitIdentity | undefined;
18
+ now?: () => number;
19
+ }
20
+ export declare function createCockpitHarness(dependencies?: CockpitHarnessDependencies): CockpitContainerHarnessModule;
21
+ /** The module-shaped entry a host resolves from the composition. */
22
+ export declare function startCockpitContainer(request: CockpitContainerRequest): Promise<CockpitContainerStart>;
23
+ export declare function reapCockpitContainer(containerId: string): Promise<boolean>;
24
+ //#endregion
25
+ export type { CockpitHarnessDependencies };
26
+ //# sourceMappingURL=cockpitHarness.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cockpitHarness.d.mts","names":[],"sources":["../src/services/cockpitPlan/index.ts","../src/services/cockpitHarness/index.ts"],"mappings":";;;;UA6CiB;EACf;EACA;;;;UChBe;EACf,SAAS;EACT;;EAEA,SAAS,iBAAiB;;EAE1B,oBAAoB;EACpB;;wBAwDc,qBAAqB,eAAc,6BAAkC;;wBAgG/D,sBAAsB,SAAS,0BAA0B,QAAQ;wBAIjE,qBAAqB,sBAAsB"}
@@ -0,0 +1,295 @@
1
+ import { a as cockpitImageTag, c as parseRunFlags, d as DEVCONTAINER_DISABLED_ENV, l as SpawnEngineProcessRunner, n as detectEngine, o as cockpitDockerfile, r as ensureImage, s as assertRunFlags, u as containerEnvironment } from "./harness-CEuhOTr2.mjs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { DOOMPI_SANDBOX_ENV } from "@agimon-ai/doompi-core/sandboxHarness";
4
+ //#region src/services/cockpitPlan/index.ts
5
+ /**
6
+ * Projects the cockpit into one detached engine run.
7
+ *
8
+ * The sandbox plan next door describes a disposable container wrapped around a
9
+ * single interactive session. This one describes a long-lived container holding
10
+ * the hub and every session it goes on to spawn, which changes four things:
11
+ * it is detached rather than attached, it publishes a port rather than only
12
+ * consuming them, its home volume is scoped to the cockpit rather than to one
13
+ * repository, and it mounts a set of workspaces rather than a single repo.
14
+ */
15
+ const CONTAINER_HOME = "/doompi-home";
16
+ /**
17
+ * One volume for the cockpit, not one per repository.
18
+ *
19
+ * A cockpit spans several workspaces, so there is no repository to key on. This
20
+ * is also where `~/.doompi/web` lands, which is what makes paired passkeys and
21
+ * the bundle signing key survive a container restart. A rotated signing key
22
+ * would make every paired device refuse the cockpit, so the name must be
23
+ * stable.
24
+ */
25
+ const COCKPIT_HOME_VOLUME = "doompi-cockpit-home";
26
+ const COCKPIT_LABEL = "doompi.cockpit";
27
+ const COCKPIT_BINARY = "doompi-web";
28
+ const LOOPBACK = "127.0.0.1";
29
+ /**
30
+ * The cockpit binds every interface inside the container.
31
+ *
32
+ * A published port reaches the container's external interface, never its
33
+ * loopback, which is the same reason `oauthCallback.ts` sets its own bind
34
+ * address. The host side of the publish is pinned to loopback, so this is not
35
+ * exposed beyond the machine.
36
+ */
37
+ const CONTAINER_BIND = "0.0.0.0";
38
+ const LINUX_PLATFORM = "linux";
39
+ const DISABLED = "0";
40
+ /**
41
+ * Git identity, passed as environment rather than by mounting a config.
42
+ *
43
+ * The agent can commit; it cannot push, because no key crosses the boundary.
44
+ * Mounting `~/.gitconfig` would drag in whatever else it configures, and
45
+ * forwarding an ssh agent would hand the container a live credential that
46
+ * authenticates as the user everywhere their key reaches.
47
+ */
48
+ function gitIdentityEnvironment(identity) {
49
+ if (identity === void 0) return {};
50
+ return {
51
+ GIT_AUTHOR_NAME: identity.name,
52
+ GIT_AUTHOR_EMAIL: identity.email,
53
+ GIT_COMMITTER_NAME: identity.name,
54
+ GIT_COMMITTER_EMAIL: identity.email
55
+ };
56
+ }
57
+ function buildCockpitPlan(input) {
58
+ const { engine, host, broker, imageTag, port } = input;
59
+ if (input.workspaces.length === 0) throw new Error("A cockpit container needs at least one workspace to mount.");
60
+ const environment = {
61
+ ...containerEnvironment(input.environment, broker),
62
+ ...gitIdentityEnvironment(input.gitIdentity),
63
+ [DEVCONTAINER_DISABLED_ENV]: DISABLED,
64
+ [DOOMPI_SANDBOX_ENV]: "1"
65
+ };
66
+ const environmentPairs = Object.entries(environment).sort(([left], [right]) => left.localeCompare(right));
67
+ return {
68
+ imageTag,
69
+ runArgs: [
70
+ "run",
71
+ "-d",
72
+ "--label",
73
+ `${COCKPIT_LABEL}=${String(port)}`,
74
+ "-p",
75
+ `${LOOPBACK}:${String(port)}:${String(port)}`,
76
+ ...input.workspaces.flatMap((workspace) => ["-v", `${workspace.path}:${workspace.path}`]),
77
+ "-v",
78
+ `${COCKPIT_HOME_VOLUME}:${CONTAINER_HOME}`,
79
+ ...broker?.endpoint.transport === "tcp" ? ["--add-host", `host.docker.internal:host-gateway`] : [],
80
+ ...broker?.endpoint.transport === "unix" ? ["-v", `${broker.endpoint.socketDirectory}:/run/doompi`] : [],
81
+ "-w",
82
+ input.workspaces[0]?.path ?? CONTAINER_HOME,
83
+ "-e",
84
+ `HOME=${CONTAINER_HOME}`,
85
+ ...environmentPairs.flatMap(([name, value]) => ["-e", `${name}=${value}`]),
86
+ ...host.platform === LINUX_PLATFORM && host.userId !== void 0 ? [
87
+ "--user",
88
+ `${String(host.userId)}:${String(host.groupId ?? host.userId)}`,
89
+ ...engine === "podman" ? ["--userns=keep-id"] : []
90
+ ] : [],
91
+ ...input.runFlags ?? [],
92
+ imageTag,
93
+ COCKPIT_BINARY,
94
+ "--host",
95
+ CONTAINER_BIND,
96
+ "--port",
97
+ String(port)
98
+ ]
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/services/cockpitHarness/index.ts
103
+ /**
104
+ * Runs the cockpit itself in a container, detached, holding the hub and every
105
+ * session it goes on to spawn.
106
+ *
107
+ * The interactive harness next door wraps one session and blocks until it
108
+ * exits. This one returns a handle while the container keeps running, which is
109
+ * why it is a separate entry rather than a flag on the other.
110
+ */
111
+ const RUN_FLAGS_ENV = "DOOMPI_SANDBOX_RUN_FLAGS";
112
+ const READY_TIMEOUT_MS = 6e4;
113
+ const READY_POLL_MS = 250;
114
+ const HUB_ROLE = "hub";
115
+ /**
116
+ * Reads the host's committer identity.
117
+ *
118
+ * Passed into the container as environment rather than by mounting the config,
119
+ * so the agent can commit without any credential crossing the boundary. It
120
+ * cannot push, and that is the intended limit.
121
+ */
122
+ function readGitIdentity() {
123
+ const read = (key) => {
124
+ try {
125
+ const value = execFileSync("git", [
126
+ "config",
127
+ "--global",
128
+ "--get",
129
+ key
130
+ ], { encoding: "utf8" }).trim();
131
+ return value === "" ? void 0 : value;
132
+ } catch {
133
+ return;
134
+ }
135
+ };
136
+ const name = read("user.name");
137
+ const email = read("user.email");
138
+ return name !== void 0 && email !== void 0 ? {
139
+ name,
140
+ email
141
+ } : void 0;
142
+ }
143
+ /** Answers whether the hub inside the container is serving yet. */
144
+ async function defaultProbe(port) {
145
+ try {
146
+ const response = await fetch(`http://127.0.0.1:${String(port)}/api/health`);
147
+ if (!response.ok) return false;
148
+ return (await response.json()).role === HUB_ROLE;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+ async function stopContainer(runner, engine, id) {
154
+ const stopped = await runner.capture(engine, ["stop", id]);
155
+ await runner.capture(engine, [
156
+ "rm",
157
+ "-f",
158
+ id
159
+ ]);
160
+ return stopped?.exitCode === 0;
161
+ }
162
+ /**
163
+ * Whether the engine still reports this container as running.
164
+ *
165
+ * A removed container makes `inspect` fail rather than print `false`, so a
166
+ * non-zero exit is read the same way: not running.
167
+ */
168
+ async function isRunning(runner, engine, id) {
169
+ const probed = await runner.capture(engine, [
170
+ "inspect",
171
+ "-f",
172
+ "{{.State.Running}}",
173
+ id
174
+ ]);
175
+ return probed?.exitCode === 0 && probed.stdout.trim() === "true";
176
+ }
177
+ function createCockpitHarness(dependencies = {}) {
178
+ const runner = dependencies.runner ?? new SpawnEngineProcessRunner();
179
+ const probe = dependencies.probe ?? defaultProbe;
180
+ const readIdentity = dependencies.gitIdentity ?? readGitIdentity;
181
+ const now = dependencies.now ?? (() => Date.now());
182
+ return {
183
+ async startCockpitContainer(request) {
184
+ if (request.workspaces.length === 0) return {
185
+ ok: false,
186
+ error: "A cockpit container needs at least one workspace to mount."
187
+ };
188
+ let engine;
189
+ try {
190
+ engine = await detectEngine(runner, request.environment);
191
+ } catch (error) {
192
+ return {
193
+ ok: false,
194
+ error: error instanceof Error ? error.message : String(error)
195
+ };
196
+ }
197
+ const runFlags = parseRunFlags(request.environment[RUN_FLAGS_ENV]);
198
+ try {
199
+ assertRunFlags(runFlags, RUN_FLAGS_ENV);
200
+ } catch (error) {
201
+ return {
202
+ ok: false,
203
+ error: error instanceof Error ? error.message : String(error)
204
+ };
205
+ }
206
+ request.onProgress?.(`using ${engine}${runFlags.length > 0 ? ` with ${runFlags.join(" ")}` : ""}`);
207
+ const version = dependencies.version ?? "0.0.0";
208
+ let imageTag;
209
+ try {
210
+ imageTag = await ensureImage(runner, engine, version, request.onProgress, {
211
+ tag: cockpitImageTag(version),
212
+ dockerfile: cockpitDockerfile()
213
+ });
214
+ } catch (error) {
215
+ return {
216
+ ok: false,
217
+ error: error instanceof Error ? error.message : String(error)
218
+ };
219
+ }
220
+ const plan = buildCockpitPlan({
221
+ workspaces: request.workspaces,
222
+ port: request.port,
223
+ environment: request.environment,
224
+ engine,
225
+ host: {
226
+ hasTty: false,
227
+ platform: process.platform,
228
+ repoKey: "",
229
+ version
230
+ },
231
+ imageTag,
232
+ runFlags,
233
+ ...readIdentity() === void 0 ? {} : { gitIdentity: readIdentity() }
234
+ });
235
+ const started = await runner.capture(engine, plan.runArgs);
236
+ const containerId = started?.stdout.trim().split("\n").pop() ?? "";
237
+ if (started?.exitCode !== 0 || containerId === "") return {
238
+ ok: false,
239
+ error: `The cockpit container did not start: ${started?.stdout.trim() ?? "no output"}`
240
+ };
241
+ request.onProgress?.("waiting for the cockpit to answer");
242
+ const deadline = now() + READY_TIMEOUT_MS;
243
+ for (;;) {
244
+ if (await probe(request.port)) break;
245
+ if (now() >= deadline) {
246
+ const logs = await runner.capture(engine, [
247
+ "logs",
248
+ "--tail",
249
+ "20",
250
+ containerId
251
+ ]);
252
+ await stopContainer(runner, engine, containerId);
253
+ return {
254
+ ok: false,
255
+ error: `The cockpit did not answer within ${String(READY_TIMEOUT_MS / 1e3)}s.\n${logs?.stdout ?? ""}`.trim()
256
+ };
257
+ }
258
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
259
+ }
260
+ return {
261
+ ok: true,
262
+ handle: {
263
+ containerId,
264
+ stop: async () => {
265
+ await stopContainer(runner, engine, containerId);
266
+ },
267
+ alive: async () => await isRunning(runner, engine, containerId)
268
+ }
269
+ };
270
+ },
271
+ async reapCockpitContainer(containerId) {
272
+ for (const engine of [
273
+ "docker",
274
+ "podman",
275
+ "nerdctl",
276
+ "finch"
277
+ ]) {
278
+ if ((await runner.capture(engine, ["--version"]))?.exitCode !== 0) continue;
279
+ return await stopContainer(runner, engine, containerId);
280
+ }
281
+ return false;
282
+ }
283
+ };
284
+ }
285
+ /** The module-shaped entry a host resolves from the composition. */
286
+ async function startCockpitContainer(request) {
287
+ return await createCockpitHarness().startCockpitContainer(request);
288
+ }
289
+ async function reapCockpitContainer(containerId) {
290
+ return await createCockpitHarness().reapCockpitContainer(containerId);
291
+ }
292
+ //#endregion
293
+ export { createCockpitHarness, reapCockpitContainer, startCockpitContainer };
294
+
295
+ //# sourceMappingURL=cockpitHarness.mjs.map