@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.
Files changed (186) hide show
  1. package/README.md +12 -0
  2. package/README.zh.md +14 -2
  3. package/cordis.patch.yml +58 -0
  4. package/dist/approval-reasons.d.ts +3 -4
  5. package/dist/approval-reasons.js +40 -49
  6. package/dist/approval-reasons.test.js +43 -15
  7. package/dist/approval.js +27 -10
  8. package/dist/approval.test.js +165 -34
  9. package/dist/card-route.d.ts +3 -2
  10. package/dist/card-route.js +103 -35
  11. package/dist/card-route.test.js +60 -43
  12. package/dist/card-test-support.js +39 -66
  13. package/dist/client/ContainerCard.d.ts +1 -1
  14. package/dist/client/ContainerCard.js +76 -70
  15. package/dist/client/card-protocol.d.ts +3 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +13 -18
  18. package/dist/client/container-card-controller.js +86 -40
  19. package/dist/client/container-card-create-modal.d.ts +2 -2
  20. package/dist/client/container-card-create-modal.js +13 -5
  21. package/dist/client/container-card-default-env.d.ts +10 -0
  22. package/dist/client/container-card-default-env.js +69 -0
  23. package/dist/client/container-card-directory.d.ts +2 -2
  24. package/dist/client/container-card-directory.js +12 -4
  25. package/dist/client/container-card-editors.d.ts +3 -3
  26. package/dist/client/container-card-editors.js +104 -32
  27. package/dist/client/container-card-images.d.ts +4 -4
  28. package/dist/client/container-card-images.js +19 -6
  29. package/dist/client/container-card-paths.d.ts +3 -3
  30. package/dist/client/container-card-paths.js +18 -11
  31. package/dist/client/container-card-row.d.ts +10 -6
  32. package/dist/client/container-card-row.js +82 -13
  33. package/dist/client/container-card-secrets.d.ts +3 -3
  34. package/dist/client/container-card-secrets.js +8 -6
  35. package/dist/client/container-card-shared.d.ts +4 -17
  36. package/dist/client/container-card-shared.js +9 -25
  37. package/dist/client/container-card-styles.d.ts +2 -8
  38. package/dist/client/container-card-styles.js +14 -52
  39. package/dist/client/container-card-volumes.d.ts +2 -2
  40. package/dist/client/container-card-volumes.js +9 -5
  41. package/dist/client/container-card-workspace.d.ts +8 -6
  42. package/dist/client/container-card-workspace.js +9 -3
  43. package/dist/client/directory-picker.js +2 -1
  44. package/dist/client/index.js +13109 -2117
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +88 -38
  47. package/dist/client/podman-terminal-guide.d.ts +11 -0
  48. package/dist/client/podman-terminal-guide.js +208 -0
  49. package/dist/client/podman-terminal-title.d.ts +7 -0
  50. package/dist/client/podman-terminal-title.js +30 -0
  51. package/dist/client/podman-terminal.d.ts +26 -0
  52. package/dist/client/podman-terminal.js +412 -0
  53. package/dist/client/read-only-approval.d.ts +2 -2
  54. package/dist/client/read-only-approval.js +13 -3
  55. package/dist/client/slot-contract.d.ts +0 -11
  56. package/dist/client/terminal-preference.d.ts +26 -0
  57. package/dist/client/terminal-preference.js +67 -0
  58. package/dist/client/terminal-protocol.d.ts +85 -0
  59. package/dist/client/terminal-protocol.js +14 -0
  60. package/dist/client/terminal-tab.d.ts +60 -0
  61. package/dist/client/terminal-tab.js +35 -0
  62. package/dist/client/terminal-targets.d.ts +33 -0
  63. package/dist/client/terminal-targets.js +65 -0
  64. package/dist/client/terminal-titles.d.ts +23 -0
  65. package/dist/client/terminal-titles.js +52 -0
  66. package/dist/client/terminal-transport.d.ts +63 -0
  67. package/dist/client/terminal-transport.js +207 -0
  68. package/dist/client/tool-views.js +215 -49
  69. package/dist/container-env.d.ts +11 -0
  70. package/dist/container-env.js +96 -0
  71. package/dist/container-env.test.d.ts +1 -0
  72. package/dist/container-env.test.js +93 -0
  73. package/dist/containers.test.js +196 -18
  74. package/dist/daemons.test.js +18 -4
  75. package/dist/dsh-version.js +2 -1
  76. package/dist/env-rows.d.ts +14 -0
  77. package/dist/env-rows.js +52 -0
  78. package/dist/env-rows.test.d.ts +1 -0
  79. package/dist/env-rows.test.js +43 -0
  80. package/dist/fs-provider.d.ts +1 -0
  81. package/dist/fs-provider.js +26 -12
  82. package/dist/fs-provider.test.js +51 -10
  83. package/dist/generated/version.d.ts +2 -2
  84. package/dist/generated/version.js +2 -2
  85. package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
  86. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  87. package/dist/grpc/runtime-client.d.ts +3 -1
  88. package/dist/grpc/runtime-client.js +90 -17
  89. package/dist/guest-rpc.d.ts +15 -19
  90. package/dist/guest-rpc.js +289 -70
  91. package/dist/guest-rpc.test.d.ts +1 -0
  92. package/dist/guest-rpc.test.js +241 -0
  93. package/dist/guest-terminal.d.ts +30 -0
  94. package/dist/guest-terminal.js +196 -0
  95. package/dist/images.test.js +10 -3
  96. package/dist/index.d.ts +11 -16
  97. package/dist/index.js +42 -36
  98. package/dist/locales.test.d.ts +1 -0
  99. package/dist/locales.test.js +34 -0
  100. package/dist/misc.test.js +38 -10
  101. package/dist/mount-enums.js +2 -1
  102. package/dist/mount-input.d.ts +2 -0
  103. package/dist/mount-input.js +38 -22
  104. package/dist/mounts.test.js +81 -11
  105. package/dist/naming.test.d.ts +1 -0
  106. package/dist/naming.test.js +28 -0
  107. package/dist/output-reader.js +36 -4
  108. package/dist/package-deps.test.d.ts +1 -0
  109. package/dist/package-deps.test.js +49 -0
  110. package/dist/paths.test.js +4 -2
  111. package/dist/plugin-meta.test.d.ts +1 -0
  112. package/dist/plugin-meta.test.js +29 -0
  113. package/dist/project-path.js +33 -7
  114. package/dist/project-path.test.js +14 -0
  115. package/dist/prompts.d.ts +0 -4
  116. package/dist/prompts.js +15 -84
  117. package/dist/read-only-shell.js +3 -1
  118. package/dist/read-only-shell.test.js +80 -25
  119. package/dist/runtime-client.test.d.ts +1 -0
  120. package/dist/runtime-client.test.js +53 -0
  121. package/dist/secrets.test.js +21 -6
  122. package/dist/settings-commands.test.js +61 -14
  123. package/dist/settings-create.test.js +32 -10
  124. package/dist/settings-default-env.test.d.ts +1 -0
  125. package/dist/settings-default-env.test.js +175 -0
  126. package/dist/settings-mounts.test.js +46 -7
  127. package/dist/settings-schema.d.ts +6 -11
  128. package/dist/settings-schema.js +4 -8
  129. package/dist/settings-schema.test.d.ts +1 -0
  130. package/dist/settings-schema.test.js +35 -0
  131. package/dist/settings-workspaces.test.js +36 -63
  132. package/dist/spill-store.d.ts +1 -0
  133. package/dist/spill-store.js +17 -4
  134. package/dist/spill-store.test.js +31 -8
  135. package/dist/subprocess.d.ts +4 -0
  136. package/dist/subprocess.js +148 -162
  137. package/dist/subprocess.test.js +118 -8
  138. package/dist/terminal-preference.test.d.ts +1 -0
  139. package/dist/terminal-preference.test.js +62 -0
  140. package/dist/terminal-route.d.ts +10 -0
  141. package/dist/terminal-route.js +270 -0
  142. package/dist/terminal-route.test.d.ts +1 -0
  143. package/dist/terminal-route.test.js +265 -0
  144. package/dist/terminal-sessions.d.ts +69 -0
  145. package/dist/terminal-sessions.js +252 -0
  146. package/dist/terminal-shells.d.ts +22 -0
  147. package/dist/terminal-shells.js +99 -0
  148. package/dist/terminal-tab.test.d.ts +1 -0
  149. package/dist/terminal-tab.test.js +58 -0
  150. package/dist/terminal-targets.test.d.ts +1 -0
  151. package/dist/terminal-targets.test.js +43 -0
  152. package/dist/terminal-titles.test.d.ts +1 -0
  153. package/dist/terminal-titles.test.js +36 -0
  154. package/dist/test-support.d.ts +35 -5
  155. package/dist/test-support.js +91 -29
  156. package/dist/tool-defs.js +19 -4
  157. package/dist/tool-handlers.js +80 -28
  158. package/dist/tool-params.js +19 -5
  159. package/dist/volumes.test.js +1 -1
  160. package/dist/workspace-binding.d.ts +18 -9
  161. package/dist/workspace-binding.js +244 -77
  162. package/dist/workspace-binding.test.js +189 -25
  163. package/docs/architecture.md +62 -21
  164. package/docs/architecture.zh.md +57 -13
  165. package/docs/configuration.md +61 -20
  166. package/docs/configuration.zh.md +53 -19
  167. package/docs/development-prompt.md +72 -0
  168. package/docs/development-prompt.zh.md +69 -0
  169. package/docs/development.md +141 -21
  170. package/docs/development.zh.md +125 -17
  171. package/docs/install-dsh-and-dsh-podman.md +21 -8
  172. package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
  173. package/docs/uninstall.md +83 -0
  174. package/docs/uninstall.zh.md +79 -0
  175. package/docs/update.md +120 -0
  176. package/docs/update.zh.md +114 -0
  177. package/docs/usage.md +108 -61
  178. package/docs/usage.zh.md +76 -38
  179. package/locale/en.json +6 -0
  180. package/locale/zh.json +6 -0
  181. package/package.json +59 -26
  182. package/quadlet/dsh-podman-orchestrator.container +46 -0
  183. package/quadlet/dsh.container +35 -0
  184. package/LICENSE.pkg +0 -17103
  185. package/dist/preferences.d.ts +0 -6
  186. package/dist/preferences.js +0 -27
@@ -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 { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
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 { PODMAN_OPS_AGENT_CORDIS_YML, PODMAN_OPS_PRESET_YML, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, TOOLS, approvalDecision, ensurePodmanOpsPreset, isSandboxEscalation, preExecutePolicy, summarizeArgs, } from "./index.js";
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: ["git", "curl", "tmux", "vim", "zsh", "openssh", "jq", "ripgrep", "make", "cc", "go"],
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 ["image_build", "container_start", "container_remove", "volume_remove"]) {
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" }, { policy: "never" }]) {
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", { container: "valkey-ctr", command: "valkey-cli ping" }, workspaceWrite), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
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", { container: "valkey-ctr", name: "v1", argv: ["valkey-server"], uid: 1001 }, workspaceWrite), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
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", { container: "c", command: "ls" }, { mode: "read-only" }), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
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"] }, { policy: "never" }), () => {
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", { container: "c", argv: ["python", "run.py", "--x", "1", "--y", "2", "--z", "3", "--w", "4", "--v", "5"] }), 'Run a command in container "c": python run.py --x 1 --y 2 --z 3 …');
365
- assert.equal(summarizeArgs("container_write", { container: "c", file_path: "/etc/valkey/valkey.conf" }), 'Write "/etc/valkey/valkey.conf" in container "c".');
366
- assert.equal(summarizeArgs("container_edit", { container: "c", file_path: "/etc/valkey/valkey.conf" }), 'Edit "/etc/valkey/valkey.conf" in container "c".');
367
- assert.equal(summarizeArgs("container_bash", { container: "c", command: "ping -c 1 8.8.8.8" }), 'Run a shell command in container "c": ping -c 1 8.8.8.8');
368
- assert.equal(summarizeArgs("container_bash", { container: "c", command: "ls", workdir: "/srv" }), 'Run a shell command in container "c" (cwd "/srv"): ls');
369
- assert.equal(summarizeArgs("container_exec", { container: "c", argv: ["ls"], workdir: "/srv" }), 'Run a command in container "c" (cwd "/srv"): ls');
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", { container: "c", argv: ["id"], gid: 2000 }), 'Run a command in container "c": id (gid 2000)');
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", { container: "c", argv: ["id"], groups: [] }), 'Run a command in container "c": id');
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(PODMAN_OPS_AGENT_CORDIS_YML.includes(tool), `podman-ops composition must mention ${tool}`);
477
+ assert.ok(patch.includes(tool), `podman-ops composition must mention ${tool}`);
400
478
  }
401
- assert.ok(PODMAN_OPS_PRESET_YML.includes("secrets"), "podman-ops metadata must mention secrets");
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(PODMAN_OPS_AGENT_CORDIS_YML, /prefix:/);
405
- assert.doesNotMatch(PODMAN_OPS_AGENT_CORDIS_YML, /\btext:/);
483
+ assert.match(patch, /prefix:/);
484
+ assert.doesNotMatch(patch, /\btext:/);
406
485
  });
407
- test("Podman-ops preset writer overwrites existing content", () => {
408
- const dir = mkdtempSync(join(tmpdir(), "dsh-podman-"));
409
- ensurePodmanOpsPreset(undefined, dir);
410
- const composition = join(dir, "agent.cordis.yml");
411
- const metadata = join(dir, "preset.yml");
412
- assert.equal(readFileSync(composition, "utf8"), PODMAN_OPS_AGENT_CORDIS_YML);
413
- assert.equal(readFileSync(metadata, "utf8"), PODMAN_OPS_PRESET_YML);
414
- writeFileSync(composition, "# stale user copy\n");
415
- ensurePodmanOpsPreset(undefined, dir);
416
- assert.equal(readFileSync(composition, "utf8"), PODMAN_OPS_AGENT_CORDIS_YML, "a second load must overwrite the stale copy");
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: { A: "1", B: "2", C: "3", D: "4", E: "5", F: "6", G: "7", H: "8", I: "9", J: "10" },
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
+ });
@@ -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(ctx: any, resolver: WorkspaceResolver, workspaceRegistry: any, raw: Record<string, unknown>): Promise<string>;
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;
@@ -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 { defaultMountMode, mountKindToProto, mountModeToProto } from "./mount-enums.js";
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
- function mountInputToProto(mount) {
42
- const kind = mountKindToProto(mount.kind || undefined);
43
- const mode = mountModeToProto(mount.mode || defaultMountMode(mount.kind || undefined));
44
- const result = { projectName: mount.project ?? "", kind, mode };
45
- if (kind !== "MOUNT_KIND_PROJECT" && mount.destination)
46
- result.destination = mount.destination;
47
- if (mount.volume)
48
- result.volume = mount.volume;
49
- if (mount.secret)
50
- result.secret = mount.secret;
51
- return result;
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 ?? "") || String(workspace.id ?? ""),
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 !== "" ? [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }] : [],
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(ctx, resolver, workspaceRegistry, raw) {
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 mounts = command.mounts.map(mountInputToProto);
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
- if (Object.keys(command.env).length > 0)
203
- payload.env = command.env;
204
- if (Object.keys(command.secretEnvMap).length > 0) {
205
- payload.secretEnv = command.secretEnvMap;
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.secretEnv,
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.secretEnv,
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(ctx?.settings), Number(result.removedFiles ?? 0));
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
- secretEnv: stringField(raw.secretEnv),
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
- secretEnvMap: stringMap(raw.secretEnvMap),
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", "settings"], (connectionCtx) => {
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
  }