@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.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 +58 -25
  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
@@ -31,6 +31,20 @@ test("confineToRoot clamps a directory to the projects root", () => {
31
31
  assert.equal(confineToRoot("/projects", ""), "/projects");
32
32
  assert.equal(confineToRoot("/projects", "/projects/web/"), "/projects/web", "trailing slashes are ignored");
33
33
  });
34
+ test("project paths resolve their dots and never escape the root", () => {
35
+ // A ".." that stays inside the root is resolved.
36
+ assert.equal(confineToRoot("/projects", "/projects/a/../b"), "/projects/b");
37
+ assert.equal(hostPathForProjectName("/projects", "a/../b"), "/projects/b");
38
+ // A ".." that would climb above the root clamps to the root, on both the
39
+ // path-building and the path-clamping helper.
40
+ assert.equal(confineToRoot("/projects", "/projects/../etc"), "/projects");
41
+ assert.equal(confineToRoot("/projects", "/projects/a/../../etc"), "/projects");
42
+ assert.equal(hostPathForProjectName("/projects", "../etc"), "/projects");
43
+ assert.equal(hostPathForProjectName("/projects", "a/../../etc"), "/projects");
44
+ assert.equal(hostPathForProjectName("/projects", "/../etc"), "/projects");
45
+ // A "." segment is dropped, not kept as a directory name.
46
+ assert.equal(hostPathForProjectName("/projects", "./web"), "/projects/web");
47
+ });
34
48
  test("parentDirectory never climbs above the projects root", () => {
35
49
  assert.equal(parentDirectory("/projects", "/projects/web/sub"), "/projects/web");
36
50
  assert.equal(parentDirectory("/projects", "/projects/web"), "/projects");
package/dist/prompts.d.ts CHANGED
@@ -7,7 +7,3 @@ export declare function podmanRuntimeSection(ctx: any): {
7
7
  scope?: unknown;
8
8
  }) => string;
9
9
  };
10
- export declare const PODMAN_OPS_PRESET_YML = "name: Podman operator mode\ndescription: Podman-focused container operations \u2014 manage images, containers, volumes, mounts, secrets, and daemons. Inspections and lifecycle run directly; commands, container file edits, daemon starts, and secret changes ask for approval; supports web research.\norder: 2\n";
11
- export declare const PODMAN_OPS_AGENT_CORDIS_YML = "- id: persona\n name: '@deepseek-ai/dsh-persona'\n config:\n suffix: Your working directory is {{cwd}}.\n prefix: >-\n You are a Podman container-operations agent powered by the {{model}} model.\n\n You manage container infrastructure through the dsh-podman tools: images\n (image_list, image_get, image_build, image_rebuild, image_rebuild_all,\n image_remove), containers (container_list, container_start,\n container_recreate, container_remove), mounts (container_mount_list,\n container_mount_add, container_mount_remove, container_mount_update),\n PATH additions (container_path_set, container_path_add,\n container_path_remove), volumes (volume_list,\n volume_create, volume_remove), secrets (secret_list, secret_create,\n secret_remove, container_secret_add, container_secret_remove), daemons\n (daemon_list, daemon_start, daemon_stop, daemon_restart, daemon_logs),\n and container inspection (container_read, container_glob,\n container_grep).\n\n Inspection and lifecycle operations run directly. Running commands inside\n a container (container_bash, container_exec), editing container files\n (container_write, container_edit), starting daemons (daemon_start),\n exposing or removing secret environment variables (container_secret_add,\n container_secret_remove), removing secrets (secret_remove), and\n rebuilding all images (image_rebuild_all) require the user's approval.\n Use web_search to research images and documentation. Use\n ask_user_question for user decisions and todo_write to track work.\n- id: tool-ask-user\n name: '@deepseek-ai/dsh-tool-ask-user'\n- id: tool-todo\n name: '@deepseek-ai/dsh-tool-todo'\n config:\n allowParallelInProgress: true\n- id: tool-web\n name: '@deepseek-ai/dsh-tool-web'\n config:\n fetch: false\n searchTimeoutMs: 60000\n";
12
- export declare function ensurePodmanOpsPreset(ctx: any, dir?: string): void;
13
- export declare function homePresetsRoot(): string;
package/dist/prompts.js CHANGED
@@ -1,10 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { PODMAN_OPS_PRESET } from "./approval.js";
5
- import { mkdirSync, writeFileSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { join } from "node:path";
8
4
  // Prompt section the harness registers to name its own on-disk checkout.
9
5
  // Mirrors @deepseek-ai/dsh-app-boot's HARNESS_SOURCE_SECTION.
10
6
  export const HARNESS_SOURCE_SECTION = "harness:source";
@@ -50,6 +46,19 @@ const RUNTIME_CONTAINER_ONLY = "This dsh session's container tools run in a Podm
50
46
  "not exist. Containers share the host kernel, so `uname -a`, `uname -r`, " +
51
47
  "and `/proc/version` do report the host kernel; the container's own identity " +
52
48
  "shows in `hostname`, `/etc/os-release`, and `/proc/1/cmdline`.";
49
+ // Storage guidance both runtime wordings carry. It names only tools that exist
50
+ // in every agent composition (`image_build`, `container_start`,
51
+ // `container_recreate`, volume mounts), so it is safe to append verbatim to the
52
+ // wording for an agent with or without the built-in tools.
53
+ const RUNTIME_STORAGE_NOTES = " To install software, prefer building a custom image with `image_build` (a " +
54
+ "short name, a base or custom parent image, and the packages to add) and " +
55
+ "then running containers from it with `container_start` or " +
56
+ "`container_recreate` (`image`); an install into a running container does " +
57
+ "not survive a recreate, while an image change does. For data that must " +
58
+ "outlive a recreate, mount a named `volume` (auto-created on first use) " +
59
+ "instead of a `tmpfs`, including the container's `/tmp` — a tool call that " +
60
+ "recreates the container (a mount or secret change, `container_recreate`, a " +
61
+ "read-only remount) clears tmpfs contents.";
53
62
  // Correct the model's host/container mental model: the built-in shell and
54
63
  // filesystem tools are container-backed too, so there is no host shell. It sits
55
64
  // just before the tool sections so the correction lands next to the tool
@@ -64,86 +73,8 @@ export function podmanRuntimeSection(ctx) {
64
73
  return {
65
74
  name: "podman:runtime",
66
75
  order: 950,
67
- text: ({ scope } = {}) => BUILTIN_TOOLS.some((name) => ctx.tools.get(name, scope) !== undefined)
76
+ text: ({ scope } = {}) => (BUILTIN_TOOLS.some((name) => ctx.tools.get(name, scope) !== undefined)
68
77
  ? RUNTIME_WITH_BUILTINS
69
- : RUNTIME_CONTAINER_ONLY,
78
+ : RUNTIME_CONTAINER_ONLY) + RUNTIME_STORAGE_NOTES,
70
79
  };
71
80
  }
72
- // The "Podman operator mode" preset metadata, shipped verbatim into the
73
- // harness's user-presets root.
74
- export const PODMAN_OPS_PRESET_YML = `name: Podman operator mode
75
- description: Podman-focused container operations — manage images, containers, volumes, mounts, secrets, and daemons. Inspections and lifecycle run directly; commands, container file edits, daemon starts, and secret changes ask for approval; supports web research.
76
- order: 2
77
- `;
78
- // The "Podman operator mode" agent composition: a Podman-focused persona plus
79
- // the built-in task and web tools. The plugin's own container tools are global
80
- // and need no rows; the command/file/daemon/secret tools stay available but
81
- // gated by the pre-execute policy (PODMAN_OPS_APPROVAL_TOOLS + global flags).
82
- export const PODMAN_OPS_AGENT_CORDIS_YML = `- id: persona
83
- name: '@deepseek-ai/dsh-persona'
84
- config:
85
- suffix: Your working directory is {{cwd}}.
86
- prefix: >-
87
- You are a Podman container-operations agent powered by the {{model}} model.
88
-
89
- You manage container infrastructure through the dsh-podman tools: images
90
- (image_list, image_get, image_build, image_rebuild, image_rebuild_all,
91
- image_remove), containers (container_list, container_start,
92
- container_recreate, container_remove), mounts (container_mount_list,
93
- container_mount_add, container_mount_remove, container_mount_update),
94
- PATH additions (container_path_set, container_path_add,
95
- container_path_remove), volumes (volume_list,
96
- volume_create, volume_remove), secrets (secret_list, secret_create,
97
- secret_remove, container_secret_add, container_secret_remove), daemons
98
- (daemon_list, daemon_start, daemon_stop, daemon_restart, daemon_logs),
99
- and container inspection (container_read, container_glob,
100
- container_grep).
101
-
102
- Inspection and lifecycle operations run directly. Running commands inside
103
- a container (container_bash, container_exec), editing container files
104
- (container_write, container_edit), starting daemons (daemon_start),
105
- exposing or removing secret environment variables (container_secret_add,
106
- container_secret_remove), removing secrets (secret_remove), and
107
- rebuilding all images (image_rebuild_all) require the user's approval.
108
- Use web_search to research images and documentation. Use
109
- ask_user_question for user decisions and todo_write to track work.
110
- - id: tool-ask-user
111
- name: '@deepseek-ai/dsh-tool-ask-user'
112
- - id: tool-todo
113
- name: '@deepseek-ai/dsh-tool-todo'
114
- config:
115
- allowParallelInProgress: true
116
- - id: tool-web
117
- name: '@deepseek-ai/dsh-tool-web'
118
- config:
119
- fetch: false
120
- searchTimeoutMs: 60000
121
- `;
122
- // Install the "Podman operator mode" agent preset into the harness's
123
- // user-presets root (~/.dsh/.agent-presets/<id>). The plugin owns the preset
124
- // content and (re)writes it on every load, so a local copy is always brought
125
- // back to the shipped composition. Best-effort — a failure only logs.
126
- export function ensurePodmanOpsPreset(ctx, dir) {
127
- const log = (message, ...args) => {
128
- try {
129
- ctx?.logger?.warn?.(message, ...args);
130
- }
131
- catch {
132
- // Logger failure must not abort preset installation.
133
- }
134
- };
135
- try {
136
- const target = dir ?? join(homePresetsRoot(), PODMAN_OPS_PRESET);
137
- mkdirSync(target, { recursive: true });
138
- writeFileSync(join(target, "preset.yml"), PODMAN_OPS_PRESET_YML);
139
- writeFileSync(join(target, "agent.cordis.yml"), PODMAN_OPS_AGENT_CORDIS_YML);
140
- log(`[dsh-podman] wrote the "${PODMAN_OPS_PRESET}" agent preset to %s`, target);
141
- }
142
- catch (error) {
143
- log(`[dsh-podman] could not install the "${PODMAN_OPS_PRESET}" agent preset: %o`, error);
144
- }
145
- }
146
- // The harness user-presets root ($DSH_HOME or ~/.dsh, plus .agent-presets).
147
- export function homePresetsRoot() {
148
- return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), ".agent-presets");
149
- }
@@ -65,7 +65,9 @@ export function createReadOnlyShellGate(deps) {
65
65
  // plugin's tools, the default container for the built-in ones.
66
66
  function containerOf(exec) {
67
67
  const container = exec?.arguments?.container;
68
- return typeof container === "string" && container !== "" ? container : "default";
68
+ return typeof container === "string" && container !== ""
69
+ ? container
70
+ : "default";
69
71
  }
70
72
  // Whether a mount kind carries a mode: project and volume mounts do; tmpfs is
71
73
  // always read-write and secret mounts take none.
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, createReadOnlyShellGate, } from "./index.js";
6
+ import { createReadOnlyShellGate, READ_ONLY_GATED_TOOLS, REMOUNT_TOOL_NAME, } from "./index.js";
7
7
  import { WORKSPACE_ID } from "./test-support.js";
8
8
  const EXEC = {
9
9
  name: "bash",
@@ -22,17 +22,15 @@ function gateFixture(mounts, options = {}) {
22
22
  async control(method, request) {
23
23
  controlCalls.push([method, request]);
24
24
  if (method === "listContainers") {
25
- return mounts === undefined
26
- ? { containers: [] }
27
- : {
28
- containers: [
29
- {
30
- workspaceSlug: WORKSPACE_ID,
31
- containerName,
32
- mounts,
33
- },
34
- ],
35
- };
25
+ return mounts === undefined ? { containers: [] } : {
26
+ containers: [
27
+ {
28
+ workspaceSlug: WORKSPACE_ID,
29
+ containerName,
30
+ mounts,
31
+ },
32
+ ],
33
+ };
36
34
  }
37
35
  return {};
38
36
  },
@@ -68,9 +66,21 @@ test("the gate covers the shell, file, and daemon tools", () => {
68
66
  });
69
67
  test("all mounts read-only allows the tool without a prompt", async () => {
70
68
  const { gate, controlCalls, approvals } = gateFixture([
71
- { projectName: "team", kind: "MOUNT_KIND_PROJECT", mode: "MOUNT_MODE_READ_ONLY" },
72
- { kind: "MOUNT_KIND_TMPFS", destination: "/scratch", mode: "MOUNT_MODE_READ_WRITE" },
73
- { kind: "MOUNT_KIND_SECRET", secret: "tls", destination: "/run/secrets/tls" },
69
+ {
70
+ projectName: "team",
71
+ kind: "MOUNT_KIND_PROJECT",
72
+ mode: "MOUNT_MODE_READ_ONLY",
73
+ },
74
+ {
75
+ kind: "MOUNT_KIND_TMPFS",
76
+ destination: "/scratch",
77
+ mode: "MOUNT_MODE_READ_WRITE",
78
+ },
79
+ {
80
+ kind: "MOUNT_KIND_SECRET",
81
+ secret: "tls",
82
+ destination: "/run/secrets/tls",
83
+ },
74
84
  ]);
75
85
  assert.deepEqual(await gate(EXEC, "en"), { kind: "allow" });
76
86
  assert.deepEqual(approvals, []);
@@ -78,10 +88,27 @@ test("all mounts read-only allows the tool without a prompt", async () => {
78
88
  });
79
89
  test("a read-write mount prompts and then remounts read-only", async () => {
80
90
  const { gate, controlCalls, approvals } = gateFixture([
81
- { projectName: "team", kind: "MOUNT_KIND_PROJECT", mode: "MOUNT_MODE_READ_WRITE" },
82
- { kind: "MOUNT_KIND_VOLUME", volume: "data", destination: "/data", mode: "MOUNT_MODE_READ_WRITE" },
83
- { kind: "MOUNT_KIND_TMPFS", destination: "/scratch", mode: "MOUNT_MODE_READ_WRITE" },
84
- { kind: "MOUNT_KIND_SECRET", secret: "tls", destination: "/run/secrets/tls" },
91
+ {
92
+ projectName: "team",
93
+ kind: "MOUNT_KIND_PROJECT",
94
+ mode: "MOUNT_MODE_READ_WRITE",
95
+ },
96
+ {
97
+ kind: "MOUNT_KIND_VOLUME",
98
+ volume: "data",
99
+ destination: "/data",
100
+ mode: "MOUNT_MODE_READ_WRITE",
101
+ },
102
+ {
103
+ kind: "MOUNT_KIND_TMPFS",
104
+ destination: "/scratch",
105
+ mode: "MOUNT_MODE_READ_WRITE",
106
+ },
107
+ {
108
+ kind: "MOUNT_KIND_SECRET",
109
+ secret: "tls",
110
+ destination: "/run/secrets/tls",
111
+ },
85
112
  ]);
86
113
  assert.deepEqual(await gate(EXEC, "en"), { kind: "allow" });
87
114
  assert.equal(approvals.length, 1);
@@ -95,15 +122,39 @@ test("a read-write mount prompts and then remounts read-only", async () => {
95
122
  workspaceSlug: WORKSPACE_ID,
96
123
  container: "default",
97
124
  mounts: [
98
- { projectName: "team", kind: "MOUNT_KIND_PROJECT", mode: "MOUNT_MODE_READ_ONLY" },
99
- { projectName: "", kind: "MOUNT_KIND_VOLUME", mode: "MOUNT_MODE_READ_ONLY", destination: "/data", volume: "data" },
100
- { projectName: "", kind: "MOUNT_KIND_TMPFS", mode: "MOUNT_MODE_READ_WRITE", destination: "/scratch" },
101
- { projectName: "", kind: "MOUNT_KIND_SECRET", destination: "/run/secrets/tls", secret: "tls" },
125
+ {
126
+ projectName: "team",
127
+ kind: "MOUNT_KIND_PROJECT",
128
+ mode: "MOUNT_MODE_READ_ONLY",
129
+ },
130
+ {
131
+ projectName: "",
132
+ kind: "MOUNT_KIND_VOLUME",
133
+ mode: "MOUNT_MODE_READ_ONLY",
134
+ destination: "/data",
135
+ volume: "data",
136
+ },
137
+ {
138
+ projectName: "",
139
+ kind: "MOUNT_KIND_TMPFS",
140
+ mode: "MOUNT_MODE_READ_WRITE",
141
+ destination: "/scratch",
142
+ },
143
+ {
144
+ projectName: "",
145
+ kind: "MOUNT_KIND_SECRET",
146
+ destination: "/run/secrets/tls",
147
+ secret: "tls",
148
+ },
102
149
  ],
103
150
  });
104
151
  });
105
152
  test("a declined prompt denies without remounting", async () => {
106
- const { gate, controlCalls, approvals } = gateFixture([{ projectName: "team", kind: "MOUNT_KIND_PROJECT", mode: "MOUNT_MODE_READ_WRITE" }], { approve: false });
153
+ const { gate, controlCalls, approvals } = gateFixture([{
154
+ projectName: "team",
155
+ kind: "MOUNT_KIND_PROJECT",
156
+ mode: "MOUNT_MODE_READ_WRITE",
157
+ }], { approve: false });
107
158
  const decision = (await gate(EXEC, "en"));
108
159
  assert.equal(decision.kind, "deny");
109
160
  assert.match(decision.reason, /mounts were not remounted/);
@@ -116,7 +167,11 @@ test("an unreadable container falls through to the plain denial", async () => {
116
167
  assert.deepEqual(approvals, []);
117
168
  });
118
169
  test("a plugin tool targets its own container", async () => {
119
- const { gate, controlCalls } = gateFixture([{ projectName: "team", kind: "MOUNT_KIND_PROJECT", mode: "MOUNT_MODE_READ_ONLY" }], { containerName: "web" });
170
+ const { gate, controlCalls } = gateFixture([{
171
+ projectName: "team",
172
+ kind: "MOUNT_KIND_PROJECT",
173
+ mode: "MOUNT_MODE_READ_ONLY",
174
+ }], { containerName: "web" });
120
175
  const exec = {
121
176
  name: "container_bash",
122
177
  arguments: { container: "web" },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { abortError, closeClients, controlClient, guestClient, unary, } from "./grpc/runtime-client.js";
7
+ test("clients are reused per socket and a close drops the cache", () => {
8
+ const first = controlClient("/run/dsh-podman/orchestrator.sock");
9
+ const second = controlClient("/run/dsh-podman/orchestrator.sock");
10
+ assert.equal(second, first, "the same socket must reuse one channel");
11
+ assert.notEqual(controlClient("/tmp/other.sock"), first, "a different socket gets its own channel");
12
+ const guest = guestClient("/run/dsh-podman/w/guest.sock");
13
+ closeClients();
14
+ assert.notEqual(controlClient("/run/dsh-podman/orchestrator.sock"), first, "a disposed client is not served from the cache");
15
+ assert.notEqual(guestClient("/run/dsh-podman/w/guest.sock"), guest);
16
+ closeClients();
17
+ });
18
+ test("unary rejects a pre-aborted call without invoking the client", async () => {
19
+ const controller = new AbortController();
20
+ controller.abort();
21
+ let called = false;
22
+ const client = {
23
+ method: () => {
24
+ called = true;
25
+ },
26
+ };
27
+ await assert.rejects(() => unary(client, "method", {}, undefined, controller.signal), (error) => error.code === "ABORTED");
28
+ assert.equal(called, false);
29
+ });
30
+ test("unary cancels a pending call when the signal aborts", async () => {
31
+ const controller = new AbortController();
32
+ let cancelled = false;
33
+ let settle;
34
+ const client = {
35
+ method: (_request, _metadata, callback) => {
36
+ settle = callback;
37
+ return {
38
+ cancel: () => {
39
+ cancelled = true;
40
+ // grpc-js settles a cancelled call with a CANCELLED status.
41
+ settle?.(Object.assign(new Error("cancelled"), { code: 1 }));
42
+ },
43
+ };
44
+ },
45
+ };
46
+ const pending = unary(client, "method", {}, undefined, controller.signal);
47
+ controller.abort();
48
+ await assert.rejects(pending, (error) => error.code === "ABORTED");
49
+ assert.equal(cancelled, true);
50
+ });
51
+ test("abortError carries a stable code", () => {
52
+ assert.equal(abortError("exec").code, "ABORTED");
53
+ });
@@ -3,8 +3,8 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { MOUNT_EXEC, SECRET_TOOLS, WORKSPACE_ID, mountRequestRecorder } from "./test-support.js";
7
- import { READ_ONLY_TOOLS, TOOLS, toolHandlers } from "./index.js";
6
+ import { MOUNT_EXEC, mountRequestRecorder, SECRET_TOOLS, WORKSPACE_ID, } from "./test-support.js";
7
+ import { READ_ONLY_TOOLS, toolHandlers, TOOLS } from "./index.js";
8
8
  test("secret tools are registered with the expected schemas", () => {
9
9
  assert.ok(READ_ONLY_TOOLS.has("secret_list"), "secret_list must be read-only");
10
10
  const listTool = TOOLS.find((entry) => entry.name === "secret_list");
@@ -29,7 +29,11 @@ test("secret tools are registered with the expected schemas", () => {
29
29
  const addTool = TOOLS.find((entry) => entry.name === "container_secret_add");
30
30
  assert.ok(addTool, "container_secret_add registered");
31
31
  assert.equal(addTool.approval, true, "container_secret_add must require approval");
32
- assert.deepEqual(addTool.parameters.required, ["container", "env", "secret"]);
32
+ assert.deepEqual(addTool.parameters.required, [
33
+ "container",
34
+ "env",
35
+ "secret",
36
+ ]);
33
37
  assert.equal(addTool.parameters.properties.secret.type, "string");
34
38
  assert.equal(addTool.parameters.properties.env.type, "string");
35
39
  const removeEnvTool = TOOLS.find((entry) => entry.name === "container_secret_remove");
@@ -86,15 +90,26 @@ test("secret mount kinds forward the secret to the orchestrator", async () => {
86
90
  });
87
91
  test("mount tools reject a read-write secret mount", async () => {
88
92
  const { requests, resolver } = mountRequestRecorder();
89
- await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", kind: "secret", secret: "tls", destination: "/run/secrets/tls", mode: "read_write" }, MOUNT_EXEC), /secret mounts are read-only/);
93
+ await assert.rejects(() => toolHandlers.container_mount_add(resolver, {
94
+ container: "web",
95
+ kind: "secret",
96
+ secret: "tls",
97
+ destination: "/run/secrets/tls",
98
+ mode: "read_write",
99
+ }, MOUNT_EXEC), /secret mounts are read-only/);
90
100
  await assert.rejects(() => toolHandlers.container_start(resolver, {
91
101
  container: "web",
92
102
  image: "img1",
93
- mounts: [{ kind: "secret", secret: "tls", destination: "/run/secrets/tls", mode: "read_write" }],
103
+ mounts: [{
104
+ kind: "secret",
105
+ secret: "tls",
106
+ destination: "/run/secrets/tls",
107
+ mode: "read_write",
108
+ }],
94
109
  }, MOUNT_EXEC), /secret mounts are read-only/);
95
110
  assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
96
111
  });
97
- test("container_list returns secret_env maps on container rows", async () => {
112
+ test("container_list returns secretEnv maps on container rows", async () => {
98
113
  const resolver = {
99
114
  registry: {
100
115
  resolveByPath: async () => ({ id: WORKSPACE_ID }),
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import { test } from "node:test";
5
5
  import { strict as assert } from "node:assert";
6
- import { fakeScope, fakeContext, baseValue, installedMountScope, mountCommand, } from "./card-test-support.js";
6
+ import { baseValue, fakeContext, fakeScope, installedMountScope, mountCommand, } from "./card-test-support.js";
7
7
  import { installCardCommandDriver } from "./card-test-support.js";
8
8
  test("remove command drives removeContainer and clears the command", async () => {
9
9
  const scope = fakeScope(baseValue());
@@ -65,27 +65,50 @@ test("cache_clean command drives cleanCaches, maps the mode, and reports the res
65
65
  return { containers: [] };
66
66
  if (method === "listImages")
67
67
  return { images: [] };
68
- if (method === "cleanCaches")
68
+ if (method === "cleanCaches") {
69
69
  return { removedFiles: 2, removedBytes: "2048" };
70
+ }
70
71
  if (method === "listCaches") {
71
- return { caches: [{ manager: "pacman", path: "/cache", files: 1, bytes: "1024" }] };
72
+ return {
73
+ caches: [{
74
+ manager: "pacman",
75
+ path: "/cache",
76
+ files: 1,
77
+ bytes: "1024",
78
+ }],
79
+ };
72
80
  }
73
81
  return {};
74
82
  },
75
83
  };
76
84
  await installCardCommandDriver(fakeContext(scope), resolver);
77
85
  await scope.update({
78
- command: { op: "cache_clean", workspace: "", image: "", at: 3, cacheMode: "all" },
86
+ command: {
87
+ op: "cache_clean",
88
+ workspace: "",
89
+ image: "",
90
+ at: 3,
91
+ cacheMode: "all",
92
+ },
79
93
  });
80
94
  await new Promise((resolve) => setImmediate(resolve));
81
95
  await scope.update({
82
- command: { op: "cache_clean", workspace: "", image: "", at: 4, cacheMode: "keep-latest" },
96
+ command: {
97
+ op: "cache_clean",
98
+ workspace: "",
99
+ image: "",
100
+ at: 4,
101
+ cacheMode: "keep-latest",
102
+ },
83
103
  });
84
104
  await new Promise((resolve) => setImmediate(resolve));
85
105
  const modes = calls
86
106
  .filter(([method]) => method === "cleanCaches")
87
107
  .map(([, request]) => request.mode);
88
- assert.deepEqual(modes, ["CACHE_CLEAN_MODE_ALL", "CACHE_CLEAN_MODE_KEEP_LATEST"]);
108
+ assert.deepEqual(modes, [
109
+ "CACHE_CLEAN_MODE_ALL",
110
+ "CACHE_CLEAN_MODE_KEEP_LATEST",
111
+ ]);
89
112
  assert.equal(scope.value.command, null);
90
113
  assert.equal(scope.value.notice, "removed 2 cached files");
91
114
  const caches = scope.value.caches;
@@ -173,7 +196,14 @@ test("secret_remove command drives removeSecret with the name", async () => {
173
196
  };
174
197
  await installCardCommandDriver(fakeContext(scope), resolver);
175
198
  await scope.update({
176
- command: { op: "secret_remove", workspace: "db-pass", image: "", at: 7, mounts: [], value: "" },
199
+ command: {
200
+ op: "secret_remove",
201
+ workspace: "db-pass",
202
+ image: "",
203
+ at: 7,
204
+ mounts: [],
205
+ value: "",
206
+ },
177
207
  });
178
208
  await new Promise((resolve) => setImmediate(resolve));
179
209
  const removeCall = calls.find(([method]) => method === "removeSecret");
@@ -203,7 +233,14 @@ test("secret_set command drives writeSecretValue with the name and value", async
203
233
  };
204
234
  await installCardCommandDriver(fakeContext(scope), resolver);
205
235
  await scope.update({
206
- command: { op: "secret_set", workspace: "db-pass", image: "", at: 8, mounts: [], value: "s3cr3t" },
236
+ command: {
237
+ op: "secret_set",
238
+ workspace: "db-pass",
239
+ image: "",
240
+ at: 8,
241
+ mounts: [],
242
+ value: "s3cr3t",
243
+ },
207
244
  });
208
245
  await new Promise((resolve) => setImmediate(resolve));
209
246
  const setCall = calls.find(([method]) => method === "writeSecretValue");
@@ -293,7 +330,12 @@ test("image_base_rebuild command drives rebuildBaseImage with the name", async (
293
330
  };
294
331
  await installCardCommandDriver(fakeContext(scope), resolver);
295
332
  await scope.update({
296
- command: { op: "image_base_rebuild", workspace: "archlinux", image: "", at: 21 },
333
+ command: {
334
+ op: "image_base_rebuild",
335
+ workspace: "archlinux",
336
+ image: "",
337
+ at: 21,
338
+ },
297
339
  });
298
340
  await new Promise((resolve) => setImmediate(resolve));
299
341
  const rebuildCall = calls.find(([method]) => method === "rebuildBaseImage");
@@ -323,7 +365,12 @@ test("image_base_pull command drives pullBaseImage with the name", async () => {
323
365
  };
324
366
  await installCardCommandDriver(fakeContext(scope), resolver);
325
367
  await scope.update({
326
- command: { op: "image_base_pull", workspace: "archlinux", image: "", at: 22 },
368
+ command: {
369
+ op: "image_base_pull",
370
+ workspace: "archlinux",
371
+ image: "",
372
+ at: 22,
373
+ },
327
374
  });
328
375
  await new Promise((resolve) => setImmediate(resolve));
329
376
  const pullCall = calls.find(([method]) => method === "pullBaseImage");
@@ -398,7 +445,7 @@ test("container_secret_add command drives addContainerSecret with env and secret
398
445
  workspace: "w",
399
446
  container: "web",
400
447
  secret: "tok",
401
- secretEnv: "TOKEN",
448
+ secretEnvName: "TOKEN",
402
449
  image: "",
403
450
  at: 16,
404
451
  },
@@ -441,7 +488,7 @@ test("container_secret_add command uses the default container when omitted", asy
441
488
  workspace: "w",
442
489
  container: "",
443
490
  secret: "tok",
444
- secretEnv: "TOKEN",
491
+ secretEnvName: "TOKEN",
445
492
  image: "",
446
493
  at: 17,
447
494
  },
@@ -483,7 +530,7 @@ test("container_secret_remove command drives removeContainerSecret with the defa
483
530
  op: "container_secret_remove",
484
531
  workspace: "w",
485
532
  container: "",
486
- secretEnv: "TOKEN",
533
+ secretEnvName: "TOKEN",
487
534
  image: "",
488
535
  at: 18,
489
536
  },
@@ -520,7 +567,7 @@ test("unset kind and mode strings map to project and read_only", async () => {
520
567
  }],
521
568
  env: {},
522
569
  container: "",
523
- secretEnvMap: {},
570
+ secretEnv: {},
524
571
  mount: null,
525
572
  },
526
573
  });