@exagone313/dsh-podman 0.2.0-rc.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +103 -35
- package/dist/card-route.test.js +60 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +76 -70
- package/dist/client/card-protocol.d.ts +3 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +13 -18
- package/dist/client/container-card-controller.js +86 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13109 -2117
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +88 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.js +2 -1
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +36 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +18 -9
- package/dist/workspace-binding.js +244 -77
- package/dist/workspace-binding.test.js +189 -25
- package/docs/architecture.md +62 -21
- package/docs/architecture.zh.md +57 -13
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +141 -21
- package/docs/development.zh.md +125 -17
- package/docs/install-dsh-and-dsh-podman.md +21 -8
- package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -26
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
package/dist/approval.test.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MIT
|
|
4
4
|
import test from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import {
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
9
7
|
import { presetExec, sessionExec, testReadSession } from "./test-support.js";
|
|
10
|
-
import {
|
|
8
|
+
import { approvalDecision, isSandboxEscalation, preExecutePolicy, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, summarizeArgs, TOOLS, } from "./index.js";
|
|
9
|
+
// The bundle patch that declares the Podman operator preset.
|
|
10
|
+
function podmanOpsPatch() {
|
|
11
|
+
return readFileSync(new URL("../cordis.patch.yml", import.meta.url), "utf8");
|
|
12
|
+
}
|
|
11
13
|
test("isSandboxEscalation claims only the harness's sandbox escalation", () => {
|
|
12
14
|
assert.equal(SANDBOX_ESCALATION_REASON_PREFIX, "escalate sandbox to ");
|
|
13
15
|
assert.equal(isSandboxEscalation({
|
|
@@ -130,7 +132,19 @@ test("summarizeArgs renders the approval reason for each gated tool", () => {
|
|
|
130
132
|
assert.equal(summarizeArgs("image_build", {
|
|
131
133
|
imageId: "dev",
|
|
132
134
|
parent: "archlinux",
|
|
133
|
-
packages: [
|
|
135
|
+
packages: [
|
|
136
|
+
"git",
|
|
137
|
+
"curl",
|
|
138
|
+
"tmux",
|
|
139
|
+
"vim",
|
|
140
|
+
"zsh",
|
|
141
|
+
"openssh",
|
|
142
|
+
"jq",
|
|
143
|
+
"ripgrep",
|
|
144
|
+
"make",
|
|
145
|
+
"cc",
|
|
146
|
+
"go",
|
|
147
|
+
],
|
|
134
148
|
}), 'Build image "dev" from "archlinux" with packages: git, curl, tmux, vim, zsh, openssh, jq, ripgrep, +3 more.');
|
|
135
149
|
assert.equal(summarizeArgs("image_rebuild", { imageId: "valkey" }), 'Rebuild image "valkey".');
|
|
136
150
|
assert.equal(summarizeArgs("image_rebuild_all", {}), "Rebuild all images.");
|
|
@@ -247,7 +261,12 @@ test("read-only permission allows get/list tools and denies the rest", async ()
|
|
|
247
261
|
assert.equal(delegated, true, `${name} must be allowed under read-only`);
|
|
248
262
|
assert.equal(result.kind, "allow");
|
|
249
263
|
}
|
|
250
|
-
for (const name of [
|
|
264
|
+
for (const name of [
|
|
265
|
+
"image_build",
|
|
266
|
+
"container_start",
|
|
267
|
+
"container_remove",
|
|
268
|
+
"volume_remove",
|
|
269
|
+
]) {
|
|
251
270
|
const result = (await preExecutePolicy(sessionExec(name), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
|
|
252
271
|
assert.equal(result.kind, "deny", `${name} must be denied under read-only`);
|
|
253
272
|
assert.ok(result.reason.includes("read-only"), `${name} deny reason`);
|
|
@@ -270,7 +289,9 @@ test("read-only denies the built-in file and shell tools", async () => {
|
|
|
270
289
|
}
|
|
271
290
|
});
|
|
272
291
|
test("workspace-write and full access delegate the built-in file and shell tools", async () => {
|
|
273
|
-
for (const facts of [{ mode: "workspace-write", policy: "ask" }, {
|
|
292
|
+
for (const facts of [{ mode: "workspace-write", policy: "ask" }, {
|
|
293
|
+
policy: "never",
|
|
294
|
+
}]) {
|
|
274
295
|
for (const name of ["write", "edit", "bash", "pwsh"]) {
|
|
275
296
|
let delegated = false;
|
|
276
297
|
const result = (await preExecutePolicy(sessionExec(name, facts), () => {
|
|
@@ -322,10 +343,18 @@ test("read-only wins over a never approval policy", async () => {
|
|
|
322
343
|
});
|
|
323
344
|
test("podman-ops preset asks for its approval-gated tools only", async () => {
|
|
324
345
|
const workspaceWrite = { mode: "workspace-write", policy: "ask" };
|
|
325
|
-
const asked = (await preExecutePolicy(presetExec("container_bash", "podman-ops", {
|
|
346
|
+
const asked = (await preExecutePolicy(presetExec("container_bash", "podman-ops", {
|
|
347
|
+
container: "valkey-ctr",
|
|
348
|
+
command: "valkey-cli ping",
|
|
349
|
+
}, workspaceWrite), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
|
|
326
350
|
assert.equal(asked.kind, "ask");
|
|
327
351
|
assert.equal(asked.reason, 'Run a shell command in container "valkey-ctr": valkey-cli ping');
|
|
328
|
-
const daemon = (await preExecutePolicy(presetExec("daemon_start", "podman-ops", {
|
|
352
|
+
const daemon = (await preExecutePolicy(presetExec("daemon_start", "podman-ops", {
|
|
353
|
+
container: "valkey-ctr",
|
|
354
|
+
name: "v1",
|
|
355
|
+
argv: ["valkey-server"],
|
|
356
|
+
uid: 1001,
|
|
357
|
+
}, workspaceWrite), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
|
|
329
358
|
assert.equal(daemon.kind, "ask");
|
|
330
359
|
assert.equal(daemon.reason, 'Start daemon "v1" in container "valkey-ctr": valkey-server (uid 1001)');
|
|
331
360
|
});
|
|
@@ -350,10 +379,17 @@ test("podman-ops keeps open tools ungated and respects permissions", async () =>
|
|
|
350
379
|
}, undefined, undefined, testReadSession));
|
|
351
380
|
assert.equal(delegated, true, "image_list must stay open in podman-ops");
|
|
352
381
|
assert.equal(open.kind, "allow");
|
|
353
|
-
const denied = (await preExecutePolicy(presetExec("container_bash", "podman-ops", {
|
|
382
|
+
const denied = (await preExecutePolicy(presetExec("container_bash", "podman-ops", {
|
|
383
|
+
container: "c",
|
|
384
|
+
command: "ls",
|
|
385
|
+
}, {
|
|
386
|
+
mode: "read-only",
|
|
387
|
+
}), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
|
|
354
388
|
assert.equal(denied.kind, "deny", "read-only wins over podman-ops approval");
|
|
355
389
|
let fullDelegated = false;
|
|
356
|
-
const full = (await preExecutePolicy(presetExec("daemon_start", "podman-ops", { container: "c", argv: ["x"] }, {
|
|
390
|
+
const full = (await preExecutePolicy(presetExec("daemon_start", "podman-ops", { container: "c", argv: ["x"] }, {
|
|
391
|
+
policy: "never",
|
|
392
|
+
}), () => {
|
|
357
393
|
fullDelegated = true;
|
|
358
394
|
return Promise.resolve({ kind: "allow" });
|
|
359
395
|
}, undefined, undefined, testReadSession));
|
|
@@ -361,12 +397,45 @@ test("podman-ops keeps open tools ungated and respects permissions", async () =>
|
|
|
361
397
|
assert.equal(full.kind, "allow");
|
|
362
398
|
});
|
|
363
399
|
test("summarizeArgs renders reasons for the podman-ops gated tools", () => {
|
|
364
|
-
assert.equal(summarizeArgs("container_exec", {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
400
|
+
assert.equal(summarizeArgs("container_exec", {
|
|
401
|
+
container: "c",
|
|
402
|
+
argv: [
|
|
403
|
+
"python",
|
|
404
|
+
"run.py",
|
|
405
|
+
"--x",
|
|
406
|
+
"1",
|
|
407
|
+
"--y",
|
|
408
|
+
"2",
|
|
409
|
+
"--z",
|
|
410
|
+
"3",
|
|
411
|
+
"--w",
|
|
412
|
+
"4",
|
|
413
|
+
"--v",
|
|
414
|
+
"5",
|
|
415
|
+
],
|
|
416
|
+
}), 'Run a command in container "c": python run.py --x 1 --y 2 --z 3 …');
|
|
417
|
+
assert.equal(summarizeArgs("container_write", {
|
|
418
|
+
container: "c",
|
|
419
|
+
file_path: "/etc/valkey/valkey.conf",
|
|
420
|
+
}), 'Write "/etc/valkey/valkey.conf" in container "c".');
|
|
421
|
+
assert.equal(summarizeArgs("container_edit", {
|
|
422
|
+
container: "c",
|
|
423
|
+
file_path: "/etc/valkey/valkey.conf",
|
|
424
|
+
}), 'Edit "/etc/valkey/valkey.conf" in container "c".');
|
|
425
|
+
assert.equal(summarizeArgs("container_bash", {
|
|
426
|
+
container: "c",
|
|
427
|
+
command: "ping -c 1 8.8.8.8",
|
|
428
|
+
}), 'Run a shell command in container "c": ping -c 1 8.8.8.8');
|
|
429
|
+
assert.equal(summarizeArgs("container_bash", {
|
|
430
|
+
container: "c",
|
|
431
|
+
command: "ls",
|
|
432
|
+
workdir: "/srv",
|
|
433
|
+
}), 'Run a shell command in container "c" (cwd "/srv"): ls');
|
|
434
|
+
assert.equal(summarizeArgs("container_exec", {
|
|
435
|
+
container: "c",
|
|
436
|
+
argv: ["ls"],
|
|
437
|
+
workdir: "/srv",
|
|
438
|
+
}), 'Run a command in container "c" (cwd "/srv"): ls');
|
|
370
439
|
});
|
|
371
440
|
test("summarizeArgs names the process identity and groups", () => {
|
|
372
441
|
assert.equal(summarizeArgs("container_bash", {
|
|
@@ -376,7 +445,11 @@ test("summarizeArgs names the process identity and groups", () => {
|
|
|
376
445
|
gid: 1000,
|
|
377
446
|
groups: [3000, 4000],
|
|
378
447
|
}), 'Run a shell command in container "c": id (uid 1000, gid 1000, groups 3000, 4000)');
|
|
379
|
-
assert.equal(summarizeArgs("container_exec", {
|
|
448
|
+
assert.equal(summarizeArgs("container_exec", {
|
|
449
|
+
container: "c",
|
|
450
|
+
argv: ["id"],
|
|
451
|
+
gid: 2000,
|
|
452
|
+
}), 'Run a command in container "c": id (gid 2000)');
|
|
380
453
|
assert.equal(summarizeArgs("daemon_start", {
|
|
381
454
|
container: "c",
|
|
382
455
|
name: "web",
|
|
@@ -385,9 +458,14 @@ test("summarizeArgs names the process identity and groups", () => {
|
|
|
385
458
|
groups: [3000],
|
|
386
459
|
}), 'Start daemon "web" in container "c": nginx (uid 1000, groups 3000)');
|
|
387
460
|
// An empty groups list is not worth naming.
|
|
388
|
-
assert.equal(summarizeArgs("container_exec", {
|
|
461
|
+
assert.equal(summarizeArgs("container_exec", {
|
|
462
|
+
container: "c",
|
|
463
|
+
argv: ["id"],
|
|
464
|
+
groups: [],
|
|
465
|
+
}), 'Run a command in container "c": id');
|
|
389
466
|
});
|
|
390
467
|
test("Podman-ops preset content covers the recent tools", () => {
|
|
468
|
+
const patch = podmanOpsPatch();
|
|
391
469
|
for (const tool of [
|
|
392
470
|
"image_rebuild_all",
|
|
393
471
|
"secret_list",
|
|
@@ -396,24 +474,26 @@ test("Podman-ops preset content covers the recent tools", () => {
|
|
|
396
474
|
"container_secret_add",
|
|
397
475
|
"container_secret_remove",
|
|
398
476
|
]) {
|
|
399
|
-
assert.ok(
|
|
477
|
+
assert.ok(patch.includes(tool), `podman-ops composition must mention ${tool}`);
|
|
400
478
|
}
|
|
401
|
-
assert.
|
|
479
|
+
assert.match(patch, /id: podman-ops/, "the preset row must carry the policy's preset id");
|
|
480
|
+
assert.ok(patch.includes("secrets"), "podman-ops metadata must mention secrets");
|
|
402
481
|
// The persona plugin takes its prose as `prefix` (a required field); `text`
|
|
403
482
|
// is not part of its schema and would make the preset fail to load.
|
|
404
|
-
assert.match(
|
|
405
|
-
assert.doesNotMatch(
|
|
483
|
+
assert.match(patch, /prefix:/);
|
|
484
|
+
assert.doesNotMatch(patch, /\btext:/);
|
|
406
485
|
});
|
|
407
|
-
test("Podman-ops preset
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
486
|
+
test("Podman-ops preset declares its composition as plugin rows", () => {
|
|
487
|
+
const patch = podmanOpsPatch();
|
|
488
|
+
for (const plugin of [
|
|
489
|
+
"@deepseek-ai/dsh-persona",
|
|
490
|
+
"@deepseek-ai/dsh-tool-ask-user",
|
|
491
|
+
"@deepseek-ai/dsh-tool-todo",
|
|
492
|
+
"@deepseek-ai/dsh-tool-web",
|
|
493
|
+
]) {
|
|
494
|
+
assert.ok(patch.includes(plugin), `podman-ops composition must declare ${plugin}`);
|
|
495
|
+
}
|
|
496
|
+
assert.ok(patch.includes("@deepseek-ai/dsh-agent-preset"), "the preset must be declared through the agent-preset row");
|
|
417
497
|
});
|
|
418
498
|
test("summarizeArgs includes PATH additions for container start/recreate", () => {
|
|
419
499
|
assert.equal(summarizeArgs("container_start", { container: "web", paths: ["/opt/bin"] }), 'Start container "web" with PATH additions: "/opt/bin".');
|
|
@@ -443,7 +523,18 @@ test("summarizeArgs includes env keys for container start/recreate", () => {
|
|
|
443
523
|
}), 'Start container "web" from image "localhost/dsh-podman/nginx:latest" with mounts: project "team" (read-only) with env: A, B, C.');
|
|
444
524
|
assert.equal(summarizeArgs("container_start", {
|
|
445
525
|
container: "web",
|
|
446
|
-
env: {
|
|
526
|
+
env: {
|
|
527
|
+
A: "1",
|
|
528
|
+
B: "2",
|
|
529
|
+
C: "3",
|
|
530
|
+
D: "4",
|
|
531
|
+
E: "5",
|
|
532
|
+
F: "6",
|
|
533
|
+
G: "7",
|
|
534
|
+
H: "8",
|
|
535
|
+
I: "9",
|
|
536
|
+
J: "10",
|
|
537
|
+
},
|
|
447
538
|
}), 'Start container "web" with env: A, B, C, D, E, F, G, H, +2 more.');
|
|
448
539
|
assert.equal(summarizeArgs("container_recreate", {
|
|
449
540
|
container: "c",
|
|
@@ -460,3 +551,43 @@ test("approval prompts name the resolved path", () => {
|
|
|
460
551
|
assert.equal(summarizeArgs("container_write", { container: "c", file_path: "notes.md" }), 'Write "notes.md" in container "c".');
|
|
461
552
|
assert.equal(summarizeArgs("container_write", { container: "c", file_path: "../x" }, "/projects/team"), 'Write "../x" in container "c".');
|
|
462
553
|
});
|
|
554
|
+
test("summarizeArgs includes secret env names for container start/recreate", () => {
|
|
555
|
+
assert.equal(summarizeArgs("container_start", {
|
|
556
|
+
container: "web",
|
|
557
|
+
secretEnv: { TOKEN: "api-token" },
|
|
558
|
+
}), 'Start container "web" with secret env: TOKEN.');
|
|
559
|
+
assert.equal(summarizeArgs("container_recreate", {
|
|
560
|
+
container: "c",
|
|
561
|
+
env: { A: "1" },
|
|
562
|
+
secretEnv: { TOKEN: "api-token" },
|
|
563
|
+
}), 'Recreate container "c" with env: A with secret env: TOKEN.');
|
|
564
|
+
// An empty map is not an attachment and is not named.
|
|
565
|
+
assert.equal(summarizeArgs("container_start", { container: "web", secretEnv: {} }), 'Start container "web".');
|
|
566
|
+
});
|
|
567
|
+
test("container_start asks for approval when it attaches secrets", () => {
|
|
568
|
+
// Plain env, paths, and a bare start stay prompt-free by design: the caller
|
|
569
|
+
// already knows those values, unlike a secret the container injects.
|
|
570
|
+
assert.equal(approvalDecision("container_start", { container: "web" }), undefined);
|
|
571
|
+
assert.equal(approvalDecision("container_start", {
|
|
572
|
+
container: "web",
|
|
573
|
+
paths: ["/opt/bin"],
|
|
574
|
+
}), undefined);
|
|
575
|
+
assert.equal(approvalDecision("container_start", { container: "web", env: { A: "1" } }), undefined);
|
|
576
|
+
assert.equal(approvalDecision("container_start", { container: "web", secretEnv: {} }), undefined);
|
|
577
|
+
// Mounts and secret attachments both change what the container can reach.
|
|
578
|
+
assert.equal(approvalDecision("container_start", {
|
|
579
|
+
container: "web",
|
|
580
|
+
mounts: [{ project: "team" }],
|
|
581
|
+
})?.kind, "ask");
|
|
582
|
+
const withSecret = approvalDecision("container_start", {
|
|
583
|
+
container: "web",
|
|
584
|
+
secretEnv: { TOKEN: "api-token" },
|
|
585
|
+
});
|
|
586
|
+
assert.equal(withSecret?.kind, "ask");
|
|
587
|
+
assert.match(withSecret?.reason ?? "", /secret env: TOKEN/);
|
|
588
|
+
});
|
|
589
|
+
test("the bundle patch disables the built-in terminal UI", () => {
|
|
590
|
+
// The Podman terminal replaces the built-in one; its client row must stay off
|
|
591
|
+
// so the guide offers a single terminal card and Ctrl+` is free.
|
|
592
|
+
assert.match(podmanOpsPatch(), /- id: ui-sidebar-terminal\n\s+disabled: true/);
|
|
593
|
+
});
|
package/dist/card-route.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CardSnapshot } from "./client/card-protocol.js";
|
|
2
|
+
import { type ReasonLocale } from "./approval-reasons.js";
|
|
2
3
|
import { type WorkspaceResolver } from "./workspace-binding.js";
|
|
3
4
|
/** Build the live snapshot the card renders. */
|
|
4
5
|
export declare function cardSnapshot(resolver: WorkspaceResolver, workspaceRegistry: any): Promise<CardSnapshot>;
|
|
@@ -6,10 +7,10 @@ export declare function cardSnapshot(resolver: WorkspaceResolver, workspaceRegis
|
|
|
6
7
|
* Run one card command, returning the notice to show (usually empty). `ctx` is
|
|
7
8
|
* the settings-injected context, read only for the session locale.
|
|
8
9
|
*/
|
|
9
|
-
export declare function runCommand(
|
|
10
|
+
export declare function runCommand(_ctx: any, resolver: WorkspaceResolver, workspaceRegistry: any, raw: Record<string, unknown>, readLocale?: () => ReasonLocale): Promise<string>;
|
|
10
11
|
/**
|
|
11
12
|
* Register the card's authenticated fetch route. It is served below the
|
|
12
13
|
* harness API path, so the carrier applies its Host/Origin fence and browser
|
|
13
14
|
* authentication before the handler runs.
|
|
14
15
|
*/
|
|
15
|
-
export declare function registerCardRoute(ctx: any, resolver: WorkspaceResolver, workspaceRegistry?: any): void;
|
|
16
|
+
export declare function registerCardRoute(ctx: any, resolver: WorkspaceResolver, workspaceRegistry?: any, readLocale?: () => ReasonLocale): void;
|
package/dist/card-route.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// request, so neither the snapshot nor the commands touch the persisted
|
|
7
7
|
// settings document.
|
|
8
8
|
import { CARD_PATH, } from "./client/card-protocol.js";
|
|
9
|
-
import { renderCacheCleanNotice, resolveReasonLocale } from "./approval-reasons.js";
|
|
10
|
-
import {
|
|
9
|
+
import { renderCacheCleanNotice, renderDefaultEnvSyncNotice, resolveReasonLocale, } from "./approval-reasons.js";
|
|
10
|
+
import { mergeDefaultEnv, missingDefaultEnv } from "./container-env.js";
|
|
11
|
+
import { defaultMountMode, mountKindToProto, mountModeToProto, } from "./mount-enums.js";
|
|
12
|
+
import { mountInputToProto, validateMountInput } from "./mount-input.js";
|
|
11
13
|
import { unaryGuest } from "./guest-rpc.js";
|
|
12
14
|
import { workspaceSlug } from "./workspace-binding.js";
|
|
13
15
|
import { GIT_COMMIT, VERSION } from "./generated/version.js";
|
|
@@ -38,17 +40,32 @@ function cacheCleanModeToProto(mode) {
|
|
|
38
40
|
throw new Error(`unknown cache clean mode: ${mode}`);
|
|
39
41
|
return proto;
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
// containerMountView and imageView rebuild the snapshot's nested objects from
|
|
44
|
+
// an explicit allow-list, so a field added to the proto later cannot reach the
|
|
45
|
+
// browser just because it exists on the wire (see AGENTS.md "Security").
|
|
46
|
+
function containerMountView(mount) {
|
|
47
|
+
return {
|
|
48
|
+
projectName: stringField(mount?.projectName),
|
|
49
|
+
destination: stringField(mount?.destination),
|
|
50
|
+
kind: stringField(mount?.kind),
|
|
51
|
+
mode: stringField(mount?.mode),
|
|
52
|
+
volume: stringField(mount?.volume),
|
|
53
|
+
secret: stringField(mount?.secret),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function imageView(image) {
|
|
57
|
+
return {
|
|
58
|
+
imageId: stringField(image?.imageId),
|
|
59
|
+
parent: stringField(image?.parent),
|
|
60
|
+
packages: Array.isArray(image?.packages) ? image.packages.map(String) : [],
|
|
61
|
+
imageTag: stringField(image?.imageTag),
|
|
62
|
+
builtAt: stringField(image?.builtAt),
|
|
63
|
+
isBase: Boolean(image?.isBase),
|
|
64
|
+
status: stringField(image?.status),
|
|
65
|
+
primitive: stringField(image?.primitive),
|
|
66
|
+
packageManager: stringField(image?.packageManager),
|
|
67
|
+
basePublic: Boolean(image?.basePublic),
|
|
68
|
+
};
|
|
52
69
|
}
|
|
53
70
|
function orchestratorWorkspaceViews(raw) {
|
|
54
71
|
return (raw ?? []).map((workspace) => ({
|
|
@@ -76,12 +93,15 @@ function dshWorkspaceViews(registry, projectsRoot) {
|
|
|
76
93
|
: path;
|
|
77
94
|
return {
|
|
78
95
|
workspaceSlug: workspaceSlug(workspace.id),
|
|
79
|
-
projectName: projectName || String(workspace.title ?? "") ||
|
|
96
|
+
projectName: projectName || String(workspace.title ?? "") ||
|
|
97
|
+
String(workspace.id ?? ""),
|
|
80
98
|
containerName: "",
|
|
81
99
|
imageId: "",
|
|
82
100
|
status: "",
|
|
83
101
|
createdAt: workspace.createdAt ?? "",
|
|
84
|
-
mounts: projectName !== ""
|
|
102
|
+
mounts: projectName !== ""
|
|
103
|
+
? [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }]
|
|
104
|
+
: [],
|
|
85
105
|
};
|
|
86
106
|
});
|
|
87
107
|
}
|
|
@@ -130,8 +150,9 @@ export async function cardSnapshot(resolver, workspaceRegistry) {
|
|
|
130
150
|
catch (error) {
|
|
131
151
|
// An incompatible plugin is refused before any handler runs, so the card
|
|
132
152
|
// reports the mismatch instead of an unexplained empty snapshot.
|
|
133
|
-
if (error?.code !== "FailedPrecondition")
|
|
153
|
+
if (error?.code !== "FailedPrecondition") {
|
|
134
154
|
throw error;
|
|
155
|
+
}
|
|
135
156
|
return {
|
|
136
157
|
version: VERSION,
|
|
137
158
|
commit: GIT_COMMIT,
|
|
@@ -162,12 +183,12 @@ export async function cardSnapshot(resolver, workspaceRegistry) {
|
|
|
162
183
|
imageId: container.imageId ?? "",
|
|
163
184
|
status: container.status ?? "",
|
|
164
185
|
createdAt: container.createdAt ?? "",
|
|
165
|
-
mounts: container.mounts ?? [],
|
|
186
|
+
mounts: (container.mounts ?? []).map(containerMountView),
|
|
166
187
|
paths: container.paths ?? [],
|
|
167
188
|
env: container.env ?? {},
|
|
168
189
|
secretEnv: container.secretEnv ?? {},
|
|
169
190
|
})),
|
|
170
|
-
images: images.images ?? [],
|
|
191
|
+
images: (images.images ?? []).map(imageView),
|
|
171
192
|
volumes: (volumes.volumes ?? []).map((volume) => ({
|
|
172
193
|
name: volume.name ?? "",
|
|
173
194
|
})),
|
|
@@ -187,7 +208,9 @@ export async function cardSnapshot(resolver, workspaceRegistry) {
|
|
|
187
208
|
* Run one card command, returning the notice to show (usually empty). `ctx` is
|
|
188
209
|
* the settings-injected context, read only for the session locale.
|
|
189
210
|
*/
|
|
190
|
-
export async function runCommand(
|
|
211
|
+
export async function runCommand(_ctx, resolver, workspaceRegistry, raw,
|
|
212
|
+
// The live UI locale, read per notice; absent in tests, which render English.
|
|
213
|
+
readLocale) {
|
|
191
214
|
const command = normalizeCommand(raw);
|
|
192
215
|
let notice = "";
|
|
193
216
|
switch (command.op) {
|
|
@@ -196,13 +219,18 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
196
219
|
workspaceSlug: command.workspace,
|
|
197
220
|
imageId: command.image === "" ? undefined : command.image,
|
|
198
221
|
};
|
|
199
|
-
const
|
|
222
|
+
const projectsRoot = resolver.getConfig().projectsRoot;
|
|
223
|
+
const mounts = command.mounts.map((mount) => mountInputToProto(mount, projectsRoot));
|
|
200
224
|
if (mounts.length > 0)
|
|
201
225
|
payload.mounts = mounts;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
226
|
+
// The card's create is a creation path: seed the default environment
|
|
227
|
+
// under whatever the modal collected. A later recreate is authoritative,
|
|
228
|
+
// which is how a seeded value is removed again.
|
|
229
|
+
const env = mergeDefaultEnv(resolver.getConfig().containerEnv, command.env);
|
|
230
|
+
if (Object.keys(env).length > 0)
|
|
231
|
+
payload.env = env;
|
|
232
|
+
if (Object.keys(command.secretEnv).length > 0) {
|
|
233
|
+
payload.secretEnv = command.secretEnv;
|
|
206
234
|
}
|
|
207
235
|
if (command.paths.length > 0)
|
|
208
236
|
payload.paths = [...command.paths];
|
|
@@ -224,6 +252,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
224
252
|
const m = command.mount;
|
|
225
253
|
if (m === null)
|
|
226
254
|
break;
|
|
255
|
+
validateMountInput(m, resolver.getConfig().projectsRoot);
|
|
227
256
|
const kind = mountKindToProto(m.kind || undefined);
|
|
228
257
|
const mode = mountModeToProto(m.mode || defaultMountMode(m.kind || undefined));
|
|
229
258
|
const request = {
|
|
@@ -282,6 +311,9 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
282
311
|
const m = command.mount;
|
|
283
312
|
if (m === null)
|
|
284
313
|
break;
|
|
314
|
+
// An update sets a mode, so a secret mount must not be made read-write
|
|
315
|
+
// here either.
|
|
316
|
+
validateMountInput(m, resolver.getConfig().projectsRoot);
|
|
285
317
|
const kind = mountKindToProto(m.kind || undefined);
|
|
286
318
|
const request = {
|
|
287
319
|
workspaceSlug: command.workspace,
|
|
@@ -321,6 +353,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
321
353
|
case "remove":
|
|
322
354
|
await resolver.control("removeContainer", {
|
|
323
355
|
workspaceSlug: command.workspace,
|
|
356
|
+
...(command.container !== "" ? { container: command.container } : {}),
|
|
324
357
|
});
|
|
325
358
|
break;
|
|
326
359
|
case "workspace_remove":
|
|
@@ -331,6 +364,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
331
364
|
case "recreate":
|
|
332
365
|
await resolver.control("recreateContainer", {
|
|
333
366
|
workspaceSlug: command.workspace,
|
|
367
|
+
...(command.container !== "" ? { container: command.container } : {}),
|
|
334
368
|
imageId: command.image === "" ? undefined : command.image,
|
|
335
369
|
...(Object.keys(command.env).length > 0 ? { env: command.env } : {}),
|
|
336
370
|
});
|
|
@@ -370,7 +404,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
370
404
|
await resolver.control("addContainerSecret", {
|
|
371
405
|
workspaceSlug: command.workspace,
|
|
372
406
|
container: command.container || "default",
|
|
373
|
-
env: command.
|
|
407
|
+
env: command.secretEnvName,
|
|
374
408
|
secret: command.secret,
|
|
375
409
|
});
|
|
376
410
|
break;
|
|
@@ -378,7 +412,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
378
412
|
await resolver.control("removeContainerSecret", {
|
|
379
413
|
workspaceSlug: command.workspace,
|
|
380
414
|
container: command.container || "default",
|
|
381
|
-
env: command.
|
|
415
|
+
env: command.secretEnvName,
|
|
382
416
|
});
|
|
383
417
|
break;
|
|
384
418
|
case "image_build":
|
|
@@ -394,11 +428,44 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
|
394
428
|
case "image_base_pull":
|
|
395
429
|
await resolver.control("pullBaseImage", { name: command.workspace });
|
|
396
430
|
break;
|
|
431
|
+
case "default_env_sync": {
|
|
432
|
+
// Add the default environment to containers that already exist. Only the
|
|
433
|
+
// keys a container is missing are added, so an explicit value is never
|
|
434
|
+
// overwritten, and a stopped container is left for its next start (which
|
|
435
|
+
// seeds the defaults). A recreate applies the merged map.
|
|
436
|
+
const defaults = resolver.getConfig().containerEnv ?? {};
|
|
437
|
+
if (Object.keys(defaults).length === 0)
|
|
438
|
+
break;
|
|
439
|
+
const listings = await resolver.control("listContainers", {});
|
|
440
|
+
let applied = 0;
|
|
441
|
+
let skipped = 0;
|
|
442
|
+
for (const row of listings.containers ?? []) {
|
|
443
|
+
if (command.workspace !== "" && row.workspaceSlug !== command.workspace) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const current = (row.env ?? {});
|
|
447
|
+
const missing = missingDefaultEnv(defaults, current);
|
|
448
|
+
if (Object.keys(missing).length === 0)
|
|
449
|
+
continue;
|
|
450
|
+
if (row.status !== "running") {
|
|
451
|
+
skipped++;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
await resolver.control("recreateContainer", {
|
|
455
|
+
workspaceSlug: row.workspaceSlug,
|
|
456
|
+
container: row.containerName,
|
|
457
|
+
env: mergeDefaultEnv(defaults, current),
|
|
458
|
+
});
|
|
459
|
+
applied++;
|
|
460
|
+
}
|
|
461
|
+
notice = renderDefaultEnvSyncNotice(resolveReasonLocale(readLocale?.()), applied, skipped);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
397
464
|
case "cache_clean": {
|
|
398
465
|
const result = (await resolver.control("cleanCaches", {
|
|
399
466
|
mode: cacheCleanModeToProto(command.cacheMode),
|
|
400
467
|
}));
|
|
401
|
-
notice = renderCacheCleanNotice(resolveReasonLocale(
|
|
468
|
+
notice = renderCacheCleanNotice(resolveReasonLocale(readLocale?.()), Number(result.removedFiles ?? 0));
|
|
402
469
|
break;
|
|
403
470
|
}
|
|
404
471
|
default:
|
|
@@ -416,8 +483,9 @@ function stringField(value, fallback = "") {
|
|
|
416
483
|
return typeof value === "string" ? value : fallback;
|
|
417
484
|
}
|
|
418
485
|
function stringMap(value) {
|
|
419
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
486
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
420
487
|
return {};
|
|
488
|
+
}
|
|
421
489
|
const result = {};
|
|
422
490
|
for (const [key, item] of Object.entries(value)) {
|
|
423
491
|
result[key] = String(item);
|
|
@@ -460,16 +528,16 @@ function normalizeCommand(raw) {
|
|
|
460
528
|
env: stringMap(raw.env),
|
|
461
529
|
container: stringField(raw.container),
|
|
462
530
|
secret: stringField(raw.secret),
|
|
463
|
-
|
|
531
|
+
secretEnvName: stringField(raw.secretEnvName),
|
|
464
532
|
length: typeof raw.length === "number" ? raw.length : 0,
|
|
465
533
|
charset: stringField(raw.charset),
|
|
466
534
|
packages: Array.isArray(raw.packages) ? raw.packages.map(String) : [],
|
|
467
|
-
|
|
535
|
+
secretEnv: stringMap(raw.secretEnv),
|
|
468
536
|
cacheMode: stringField(raw.cacheMode),
|
|
469
537
|
mount: mountField(raw.mount),
|
|
470
538
|
};
|
|
471
539
|
}
|
|
472
|
-
async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
|
|
540
|
+
async function handleCardRequest(request, ctx, resolver, workspaceRegistry, readLocale) {
|
|
473
541
|
if (request.method === "GET") {
|
|
474
542
|
try {
|
|
475
543
|
return jsonResponse(200, await cardSnapshot(resolver, workspaceRegistry));
|
|
@@ -495,7 +563,7 @@ async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
|
|
|
495
563
|
return jsonResponse(400, { error: "a command is required" });
|
|
496
564
|
}
|
|
497
565
|
try {
|
|
498
|
-
const notice = await runCommand(ctx, resolver, workspaceRegistry, command);
|
|
566
|
+
const notice = await runCommand(ctx, resolver, workspaceRegistry, command, readLocale);
|
|
499
567
|
const result = notice === "" ? {} : { notice };
|
|
500
568
|
return jsonResponse(200, result);
|
|
501
569
|
}
|
|
@@ -510,13 +578,13 @@ async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
|
|
|
510
578
|
* harness API path, so the carrier applies its Host/Origin fence and browser
|
|
511
579
|
* authentication before the handler runs.
|
|
512
580
|
*/
|
|
513
|
-
export function registerCardRoute(ctx, resolver, workspaceRegistry) {
|
|
514
|
-
ctx.inject(["connection"
|
|
581
|
+
export function registerCardRoute(ctx, resolver, workspaceRegistry, readLocale) {
|
|
582
|
+
ctx.inject(["connection"], (connectionCtx) => {
|
|
515
583
|
connectionCtx.connection.fetch.register({
|
|
516
584
|
path: CARD_PATH,
|
|
517
585
|
methods: ["GET", "POST"],
|
|
518
586
|
requestBody: "buffered",
|
|
519
|
-
fetch: (request) => handleCardRequest(request, connectionCtx, resolver, workspaceRegistry),
|
|
587
|
+
fetch: (request) => handleCardRequest(request, connectionCtx, resolver, workspaceRegistry, readLocale),
|
|
520
588
|
});
|
|
521
589
|
});
|
|
522
590
|
}
|