@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.
- package/LICENSE +21 -0
- package/README.md +224 -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/brokerHost-BeGtmnK8.mjs +302 -0
- package/dist/brokerHost-BeGtmnK8.mjs.map +1 -0
- package/dist/brokerHost-BzffXeb2.cjs +340 -0
- package/dist/brokerHost-BzffXeb2.cjs.map +1 -0
- package/dist/cockpitHarness.cjs +298 -0
- package/dist/cockpitHarness.cjs.map +1 -0
- package/dist/cockpitHarness.d.mts +26 -0
- package/dist/cockpitHarness.d.mts.map +1 -0
- package/dist/cockpitHarness.mjs +295 -0
- package/dist/cockpitHarness.mjs.map +1 -0
- package/dist/extensionService-BnIZjdJN.cjs +25 -0
- package/dist/extensionService-BnIZjdJN.cjs.map +1 -0
- package/dist/extensionService-DiVpVLn9.mjs +20 -0
- package/dist/extensionService-DiVpVLn9.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/pi.cjs +85 -0
- package/dist/extensions/pi.cjs.map +1 -0
- package/dist/extensions/pi.d.mts +6 -0
- package/dist/extensions/pi.d.mts.map +1 -0
- package/dist/extensions/pi.mjs +80 -0
- package/dist/extensions/pi.mjs.map +1 -0
- package/dist/extensions/server.cjs +92 -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 +87 -0
- package/dist/extensions/server.mjs.map +1 -0
- package/dist/harness-CEuhOTr2.mjs +716 -0
- package/dist/harness-CEuhOTr2.mjs.map +1 -0
- package/dist/harness-fK8CSaU1.cjs +785 -0
- package/dist/harness-fK8CSaU1.cjs.map +1 -0
- package/dist/index-CBt-PlVG.d.mts +22 -0
- package/dist/index-CBt-PlVG.d.mts.map +1 -0
- package/dist/index.cjs +3 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/sandboxBridge-B3FRZkC2.cjs +131 -0
- package/dist/sandboxBridge-B3FRZkC2.cjs.map +1 -0
- package/dist/sandboxBridge-D6RAncmy.mjs +72 -0
- package/dist/sandboxBridge-D6RAncmy.mjs.map +1 -0
- package/dist/sandboxHarness-BIvuRMio.d.mts +38 -0
- package/dist/sandboxHarness-BIvuRMio.d.mts.map +1 -0
- package/dist/sandboxHarness.cjs +4 -0
- package/dist/sandboxHarness.d.mts +38 -0
- package/dist/sandboxHarness.d.mts.map +1 -0
- package/dist/sandboxHarness.mjs +2 -0
- package/dist/sandboxResources-5pvXyBW9.mjs +79 -0
- package/dist/sandboxResources-5pvXyBW9.mjs.map +1 -0
- package/dist/sandboxResources-Djxj5l1V.cjs +120 -0
- package/dist/sandboxResources-Djxj5l1V.cjs.map +1 -0
- package/llms.txt +11 -0
- package/package.json +132 -0
- 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":"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"}
|
|
@@ -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
|