@exagone313/dsh-podman 0.2.0-rc.3 → 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 (191) hide show
  1. package/README.md +14 -2
  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 +154 -42
  11. package/dist/card-route.test.js +85 -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 +92 -50
  15. package/dist/client/card-protocol.d.ts +6 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +16 -18
  18. package/dist/client/container-card-controller.js +92 -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 +13141 -2036
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +106 -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.d.ts +1 -0
  76. package/dist/dsh-version.js +46 -0
  77. package/dist/env-rows.d.ts +14 -0
  78. package/dist/env-rows.js +52 -0
  79. package/dist/env-rows.test.d.ts +1 -0
  80. package/dist/env-rows.test.js +43 -0
  81. package/dist/fs-provider.d.ts +1 -0
  82. package/dist/fs-provider.js +26 -12
  83. package/dist/fs-provider.test.js +51 -10
  84. package/dist/generated/version.d.ts +2 -2
  85. package/dist/generated/version.js +2 -2
  86. package/dist/grpc/proto/dshctl/v1/control.proto +11 -1
  87. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  88. package/dist/grpc/runtime-client.d.ts +3 -1
  89. package/dist/grpc/runtime-client.js +90 -17
  90. package/dist/guest-rpc.d.ts +15 -19
  91. package/dist/guest-rpc.js +289 -70
  92. package/dist/guest-rpc.test.d.ts +1 -0
  93. package/dist/guest-rpc.test.js +241 -0
  94. package/dist/guest-terminal.d.ts +30 -0
  95. package/dist/guest-terminal.js +196 -0
  96. package/dist/images.test.js +10 -3
  97. package/dist/index.d.ts +11 -16
  98. package/dist/index.js +42 -36
  99. package/dist/locales.test.d.ts +1 -0
  100. package/dist/locales.test.js +34 -0
  101. package/dist/misc.test.js +38 -10
  102. package/dist/mount-enums.js +2 -1
  103. package/dist/mount-input.d.ts +2 -0
  104. package/dist/mount-input.js +38 -22
  105. package/dist/mounts.test.js +81 -11
  106. package/dist/naming.test.d.ts +1 -0
  107. package/dist/naming.test.js +28 -0
  108. package/dist/output-reader.js +36 -4
  109. package/dist/package-deps.test.d.ts +1 -0
  110. package/dist/package-deps.test.js +49 -0
  111. package/dist/paths.test.js +4 -2
  112. package/dist/plugin-meta.test.d.ts +1 -0
  113. package/dist/plugin-meta.test.js +29 -0
  114. package/dist/project-path.js +33 -7
  115. package/dist/project-path.test.js +14 -0
  116. package/dist/prompts.d.ts +0 -4
  117. package/dist/prompts.js +15 -84
  118. package/dist/read-only-shell.js +3 -1
  119. package/dist/read-only-shell.test.js +80 -25
  120. package/dist/runtime-client.test.d.ts +1 -0
  121. package/dist/runtime-client.test.js +53 -0
  122. package/dist/secrets.test.js +21 -6
  123. package/dist/settings-commands.test.js +61 -14
  124. package/dist/settings-create.test.js +32 -10
  125. package/dist/settings-default-env.test.d.ts +1 -0
  126. package/dist/settings-default-env.test.js +175 -0
  127. package/dist/settings-mounts.test.js +46 -7
  128. package/dist/settings-schema.d.ts +6 -11
  129. package/dist/settings-schema.js +4 -8
  130. package/dist/settings-schema.test.d.ts +1 -0
  131. package/dist/settings-schema.test.js +35 -0
  132. package/dist/settings-workspaces.test.js +38 -63
  133. package/dist/spill-store.d.ts +1 -0
  134. package/dist/spill-store.js +17 -4
  135. package/dist/spill-store.test.js +31 -8
  136. package/dist/subprocess.d.ts +4 -0
  137. package/dist/subprocess.js +148 -162
  138. package/dist/subprocess.test.js +118 -8
  139. package/dist/terminal-preference.test.d.ts +1 -0
  140. package/dist/terminal-preference.test.js +62 -0
  141. package/dist/terminal-route.d.ts +10 -0
  142. package/dist/terminal-route.js +270 -0
  143. package/dist/terminal-route.test.d.ts +1 -0
  144. package/dist/terminal-route.test.js +265 -0
  145. package/dist/terminal-sessions.d.ts +69 -0
  146. package/dist/terminal-sessions.js +252 -0
  147. package/dist/terminal-shells.d.ts +22 -0
  148. package/dist/terminal-shells.js +99 -0
  149. package/dist/terminal-tab.test.d.ts +1 -0
  150. package/dist/terminal-tab.test.js +58 -0
  151. package/dist/terminal-targets.test.d.ts +1 -0
  152. package/dist/terminal-targets.test.js +43 -0
  153. package/dist/terminal-titles.test.d.ts +1 -0
  154. package/dist/terminal-titles.test.js +36 -0
  155. package/dist/test-support.d.ts +35 -5
  156. package/dist/test-support.js +91 -29
  157. package/dist/tool-defs.js +19 -4
  158. package/dist/tool-handlers.js +80 -28
  159. package/dist/tool-params.js +19 -5
  160. package/dist/version-compat.d.ts +4 -0
  161. package/dist/version-compat.js +29 -0
  162. package/dist/version-compat.test.d.ts +1 -0
  163. package/dist/version-compat.test.js +27 -0
  164. package/dist/volumes.test.js +1 -1
  165. package/dist/workspace-binding.d.ts +19 -10
  166. package/dist/workspace-binding.js +252 -78
  167. package/dist/workspace-binding.test.js +202 -27
  168. package/docs/architecture.md +71 -20
  169. package/docs/architecture.zh.md +62 -12
  170. package/docs/configuration.md +61 -20
  171. package/docs/configuration.zh.md +53 -19
  172. package/docs/development-prompt.md +72 -0
  173. package/docs/development-prompt.zh.md +69 -0
  174. package/docs/development.md +184 -29
  175. package/docs/development.zh.md +165 -26
  176. package/docs/install-dsh-and-dsh-podman.md +24 -21
  177. package/docs/install-dsh-and-dsh-podman.zh.md +20 -21
  178. package/docs/uninstall.md +83 -0
  179. package/docs/uninstall.zh.md +79 -0
  180. package/docs/update.md +120 -0
  181. package/docs/update.zh.md +114 -0
  182. package/docs/usage.md +108 -61
  183. package/docs/usage.zh.md +76 -38
  184. package/locale/en.json +6 -0
  185. package/locale/zh.json +6 -0
  186. package/package.json +58 -25
  187. package/quadlet/dsh-podman-orchestrator.container +46 -0
  188. package/quadlet/dsh.container +35 -0
  189. package/LICENSE.pkg +0 -17103
  190. package/dist/preferences.d.ts +0 -6
  191. package/dist/preferences.js +0 -27
@@ -0,0 +1,34 @@
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
+ // Importing the client dictionary here also typechecks it under the host
7
+ // program, which has no DOM lib: it must stay environment-free data.
8
+ import { en, zh } from "./client/locales.js";
9
+ // A confirmation that acts on one object must name it. Each call site passes the
10
+ // placeholder listed here, so dropping a subject from either language fails.
11
+ const SUBJECTS = [
12
+ ["confirmRemovePod", "workspace"],
13
+ ["confirmRemoveContainer", "workspace"],
14
+ ["confirmRemoveContainer", "container"],
15
+ ["confirmRecreate", "workspace"],
16
+ ["confirmRecreate", "container"],
17
+ ["confirmDetachSecret", "env"],
18
+ ["confirmDetachSecret", "secret"],
19
+ ["confirmRebuildImage", "image"],
20
+ ["confirmRemoveImage", "image"],
21
+ ["confirmRemoveVolume", "volume"],
22
+ ["confirmSetSecret", "secret"],
23
+ ["confirmRemoveSecret", "secret"],
24
+ ["confirmRemountReadOnly", "mount"],
25
+ ["confirmRemountReadWrite", "mount"],
26
+ ["confirmRemoveMount", "mount"],
27
+ ];
28
+ test("every object-naming confirmation interpolates its subject", () => {
29
+ for (const [key, placeholder] of SUBJECTS) {
30
+ for (const [language, dictionary] of [["en", en], ["zh", zh]]) {
31
+ assert.ok(dictionary[key].includes(`{${placeholder}}`), `${language} ${key} must name {${placeholder}}`);
32
+ }
33
+ }
34
+ });
package/dist/misc.test.js CHANGED
@@ -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 { fakeToolContext, guestExecRecorder, guestFileRecorder } from "./test-support.js";
7
- import { HARNESS_SOURCE_SECTION, TOOLS, toolCallView, toolHandlers, toolResultView, withoutHarnessSourceSection, } from "./index.js";
6
+ import { fakeToolContext, guestExecRecorder, guestFileRecorder, } from "./test-support.js";
7
+ import { HARNESS_SOURCE_SECTION, toolCallView, toolHandlers, toolResultView, TOOLS, withoutHarnessSourceSection, } from "./index.js";
8
8
  test("every tool parameters is a valid JSON-Schema object", () => {
9
9
  for (const tool of TOOLS) {
10
10
  assert.equal(tool.parameters.type, "object", `${tool.name} type`);
@@ -27,11 +27,20 @@ test("file tools resolve relative paths against the session cwd", async () => {
27
27
  assert.deepEqual(absolute.reads, ["/etc/hosts"], "absolute paths pass through");
28
28
  const write = guestFileRecorder();
29
29
  await toolHandlers.container_write(write.resolver, { container: "default", file_path: "out.txt", content: "x" }, exec, fakeToolContext(write.resolver));
30
- assert.deepEqual(write.writes.map((entry) => entry.path), ["/projects/team/out.txt"]);
30
+ assert.deepEqual(write.writes.map((entry) => entry.path), [
31
+ "/projects/team/out.txt",
32
+ ]);
31
33
  const edit = guestFileRecorder("hello");
32
- await toolHandlers.container_edit(edit.resolver, { container: "default", file_path: "a.txt", old_string: "hello", new_string: "bye" }, exec, fakeToolContext(edit.resolver));
34
+ await toolHandlers.container_edit(edit.resolver, {
35
+ container: "default",
36
+ file_path: "a.txt",
37
+ old_string: "hello",
38
+ new_string: "bye",
39
+ }, exec, fakeToolContext(edit.resolver));
33
40
  assert.deepEqual(edit.reads, ["/projects/team/a.txt"]);
34
- assert.deepEqual(edit.writes.map((entry) => entry.path), ["/projects/team/a.txt"]);
41
+ assert.deepEqual(edit.writes.map((entry) => entry.path), [
42
+ "/projects/team/a.txt",
43
+ ]);
35
44
  });
36
45
  test("file tools refuse traversal before reaching the guest", async () => {
37
46
  const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
@@ -47,8 +56,14 @@ test("tool presenters label every tool and render terminal commands", () => {
47
56
  for (const tool of TOOLS) {
48
57
  assert.notEqual(toolCallView(tool.name, {}), undefined, `${tool.name} has a call view`);
49
58
  }
50
- assert.deepEqual(toolCallView("container_bash", { command: "ls", description: "List files" }), { card: "terminal", title: "ls", description: "List files" });
51
- assert.deepEqual(toolCallView("container_exec", { argv: ["ls", "-la"], description: "List all" }), { card: "terminal", title: "ls -la", description: "List all" });
59
+ assert.deepEqual(toolCallView("container_bash", {
60
+ command: "ls",
61
+ description: "List files",
62
+ }), { card: "terminal", title: "ls", description: "List files" });
63
+ assert.deepEqual(toolCallView("container_exec", {
64
+ argv: ["ls", "-la"],
65
+ description: "List all",
66
+ }), { card: "terminal", title: "ls -la", description: "List all" });
52
67
  assert.deepEqual(toolCallView("image_list", {}), { card: "generic", title: "List images", kind: "search" });
53
68
  assert.deepEqual(toolCallView("container_read", {}), { card: "generic", title: "Container read", kind: "read" });
54
69
  assert.deepEqual(toolCallView("container_write", {}), { card: "generic", title: "Container write", kind: "edit" });
@@ -63,7 +78,10 @@ test("tool presenters render command results as terminal output", () => {
63
78
  });
64
79
  assert.deepEqual(toolResultView("container_bash", {}, result({ exitCode: 0, signal: null, stdout: "hi\n", stderr: "" })), { card: "terminal", output: "hi\n", exitCode: 0 });
65
80
  assert.deepEqual(toolResultView("container_bash", {}, result({ exitCode: 0, signal: "SIGTERM", stdout: "", stderr: "" })), { card: "terminal", output: "", signal: "SIGTERM" });
66
- assert.equal(toolResultView("container_bash", {}, { content: [{ type: "text", text: "boom" }], isError: true }), undefined);
81
+ assert.equal(toolResultView("container_bash", {}, {
82
+ content: [{ type: "text", text: "boom" }],
83
+ isError: true,
84
+ }), undefined);
67
85
  assert.equal(toolResultView("image_list", {}, result({})), undefined);
68
86
  });
69
87
  test("command tools resolve a relative working directory", async () => {
@@ -89,9 +107,19 @@ test("command tools pass an optional uid, gid, and groups", async () => {
89
107
  await toolHandlers.container_bash(bash.resolver, { container: "default", command: "id", uid: 1000 }, exec);
90
108
  // The managed environment is asserted separately; this pins the identity.
91
109
  const { env: _bashEnv, ...bashStart } = bash.starts[0];
92
- assert.deepEqual(bashStart, { argv: ["bash", "-c", "id"], cwd: "/projects/team", uid: 1000 });
110
+ assert.deepEqual(bashStart, {
111
+ argv: ["bash", "-c", "id"],
112
+ cwd: "/projects/team",
113
+ uid: 1000,
114
+ });
93
115
  const run = guestExecRecorder("/projects/team");
94
- await toolHandlers.container_exec(run.resolver, { container: "default", argv: ["id"], uid: 1000, gid: 2000, groups: [3000, 4000] }, exec);
116
+ await toolHandlers.container_exec(run.resolver, {
117
+ container: "default",
118
+ argv: ["id"],
119
+ uid: 1000,
120
+ gid: 2000,
121
+ groups: [3000, 4000],
122
+ }, exec);
95
123
  const { env: _runEnv, ...runStart } = run.starts[0];
96
124
  assert.deepEqual(runStart, {
97
125
  argv: ["id"],
@@ -56,7 +56,8 @@ export function forcedMountMode(kind) {
56
56
  return undefined;
57
57
  }
58
58
  export function mountModeToProto(mode) {
59
- const proto = mode !== undefined && Object.prototype.hasOwnProperty.call(MOUNT_MODES, mode)
59
+ const proto = mode !== undefined &&
60
+ Object.prototype.hasOwnProperty.call(MOUNT_MODES, mode)
60
61
  ? MOUNT_MODES[mode]
61
62
  : undefined;
62
63
  if (proto === undefined)
@@ -11,4 +11,6 @@ export declare function projectMountDestinationReason(projectsRoot: string | und
11
11
  project?: unknown;
12
12
  destination?: unknown;
13
13
  }, locale?: ReasonLocale): string | undefined;
14
+ export declare function validateMountInput(mount: any, projectsRoot: string | undefined): void;
15
+ export declare function mountInputToProto(mount: any, projectsRoot: string | undefined): Record<string, unknown>;
14
16
  export declare function mountsFromInput(mounts: unknown, projectsRoot: string | undefined): Record<string, unknown>[] | undefined;
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { defaultMountMode, mountKindToProto, mountModeToProto } from "./mount-enums.js";
4
+ import { defaultMountMode, mountKindToProto, mountModeToProto, } from "./mount-enums.js";
5
5
  import { renderDenial } from "./approval-reasons.js";
6
6
  // The mount kind for a call: an explicit non-empty `kind` wins, otherwise it is
7
7
  // inferred from the source field the caller supplied (`secret`/`volume`), since
@@ -26,8 +26,9 @@ export function projectMountMirror(projectsRoot, project) {
26
26
  export function projectMountDestinationReason(projectsRoot, args, locale = "en") {
27
27
  if (inferMountKind(args) !== "project")
28
28
  return undefined;
29
- if (args.destination === undefined || args.destination === "")
29
+ if (args.destination === undefined || args.destination === "") {
30
30
  return undefined;
31
+ }
31
32
  const project = typeof args.project === "string" ? args.project : "";
32
33
  const mirror = projectsRoot === undefined
33
34
  ? ""
@@ -38,27 +39,42 @@ export function projectMountDestinationReason(projectsRoot, args, locale = "en")
38
39
  ...(mirror === "" ? {} : { mirror }),
39
40
  });
40
41
  }
42
+ // validateMountInput applies the rules a mount must satisfy before it reaches
43
+ // the control plane: a project mount never takes a destination, and a secret
44
+ // mount is read-only. Both the model tools and the settings card call this, so
45
+ // the same rule cannot hold on one path and not the other.
46
+ export function validateMountInput(mount, projectsRoot) {
47
+ const destinationReason = projectMountDestinationReason(projectsRoot, mount);
48
+ if (destinationReason !== undefined)
49
+ throw new Error(destinationReason);
50
+ if (inferMountKind(mount) === "secret" && mount?.mode === "read_write") {
51
+ throw new Error("secret mounts are read-only; omit mode or use read_only");
52
+ }
53
+ }
54
+ // mountInputToProto validates one mount and encodes it for the control plane.
55
+ // It is the single encoder both entry points use.
56
+ export function mountInputToProto(mount, projectsRoot) {
57
+ validateMountInput(mount, projectsRoot);
58
+ const inferredKind = inferMountKind(mount);
59
+ const kind = mountKindToProto(inferredKind);
60
+ // `||`, not `??`: the card sends an empty string for an unset mode.
61
+ const mode = mountModeToProto(mount?.mode || defaultMountMode(inferredKind));
62
+ const result = {
63
+ projectName: mount?.project ?? "",
64
+ kind,
65
+ mode,
66
+ };
67
+ if (inferredKind !== "project" && mount?.destination) {
68
+ result.destination = mount.destination;
69
+ }
70
+ if (mount?.volume)
71
+ result.volume = mount.volume;
72
+ if (mount?.secret)
73
+ result.secret = mount.secret;
74
+ return result;
75
+ }
41
76
  export function mountsFromInput(mounts, projectsRoot) {
42
77
  if (!Array.isArray(mounts) || mounts.length === 0)
43
78
  return undefined;
44
- return mounts.map((mount) => {
45
- const inferredKind = inferMountKind(mount);
46
- const destinationReason = projectMountDestinationReason(projectsRoot, mount);
47
- if (destinationReason !== undefined)
48
- throw new Error(destinationReason);
49
- if (inferredKind === "secret" && mount.mode === "read_write") {
50
- throw new Error("secret mounts are read-only; omit mode or use read_only");
51
- }
52
- const kind = mountKindToProto(inferredKind);
53
- const mode = mountModeToProto(mount.mode ?? defaultMountMode(inferredKind));
54
- const result = { projectName: mount.project ?? "", kind, mode };
55
- if (inferredKind !== "project" && mount.destination) {
56
- result.destination = mount.destination;
57
- }
58
- if (mount.volume)
59
- result.volume = mount.volume;
60
- if (mount.secret)
61
- result.secret = mount.secret;
62
- return result;
63
- });
79
+ return mounts.map((mount) => mountInputToProto(mount, projectsRoot));
64
80
  }
@@ -3,9 +3,37 @@
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, MOUNT_TOOLS, guestExecRecorder, mountRequestRecorder, testReadSession, WORKSPACE_ID, } from "./test-support.js";
7
- import { TOOLS, approvalDecision, preExecutePolicy, summarizeArgs, toolHandlers } from "./index.js";
6
+ import { guestExecRecorder, MOUNT_EXEC, MOUNT_TOOLS, mountRequestRecorder, testReadSession, WORKSPACE_ID, } from "./test-support.js";
7
+ import { approvalDecision, preExecutePolicy, summarizeArgs, toolHandlers, TOOLS, } from "./index.js";
8
8
  import { forcedMountMode } from "./mount-enums.js";
9
+ import { mountInputToProto, mountsFromInput, validateMountInput, } from "./mount-input.js";
10
+ test("mount validation is shared between the tools and the card", () => {
11
+ // A secret mount is read-only on every entry point.
12
+ assert.throws(() => validateMountInput({ kind: "secret", secret: "s", mode: "read_write" }, "/projects"), /read-only/);
13
+ assert.throws(() => mountInputToProto({ kind: "secret", secret: "s", mode: "read_write" }, "/projects"), /read-only/);
14
+ // A project mount never takes a destination.
15
+ assert.throws(() => mountInputToProto({ kind: "project", project: "p", destination: "/x" }, "/projects"), /destination/);
16
+ assert.deepEqual(mountInputToProto({ kind: "project", project: "team" }, "/projects"), {
17
+ projectName: "team",
18
+ kind: "MOUNT_KIND_PROJECT",
19
+ mode: "MOUNT_MODE_READ_ONLY",
20
+ });
21
+ assert.deepEqual(mountInputToProto({ kind: "secret", secret: "s", destination: "/run/s" }, "/projects"), {
22
+ projectName: "",
23
+ kind: "MOUNT_KIND_SECRET",
24
+ mode: "MOUNT_MODE_READ_ONLY",
25
+ destination: "/run/s",
26
+ secret: "s",
27
+ });
28
+ assert.deepEqual(mountsFromInput([{ kind: "tmpfs", destination: "/data" }], "/projects"), [
29
+ {
30
+ projectName: "",
31
+ kind: "MOUNT_KIND_TMPFS",
32
+ mode: "MOUNT_MODE_READ_WRITE",
33
+ destination: "/data",
34
+ },
35
+ ]);
36
+ });
9
37
  test("container_start approval depends on mounts being passed", () => {
10
38
  const tool = TOOLS.find((entry) => entry.name === "container_start");
11
39
  assert.ok(tool, "container_start registered");
@@ -38,7 +66,11 @@ test("summarizeArgs infers the mount target when kind is omitted", () => {
38
66
  test("preExecutePolicy denies project mounts that carry a destination", async () => {
39
67
  const deny = (await preExecutePolicy({
40
68
  name: "container_mount_add",
41
- arguments: { kind: "project", project: "team/src", destination: "/custom" },
69
+ arguments: {
70
+ kind: "project",
71
+ project: "team/src",
72
+ destination: "/custom",
73
+ },
42
74
  }, () => Promise.resolve({ kind: "allow" }), () => "/projects"));
43
75
  assert.equal(deny.kind, "deny", "must deny instead of asking");
44
76
  assert.equal(deny.reason, 'Project mounts cannot set a destination; "team/src" always mounts at "/projects/team/src".');
@@ -140,8 +172,18 @@ test("an omitted mount kind is inferred from the source field", async () => {
140
172
  });
141
173
  test("container_mount_update maps the selector and mode", async () => {
142
174
  const { requests, resolver } = mountRequestRecorder();
143
- await toolHandlers.container_mount_update(resolver, { container: "valkey-ctr", kind: "project", project: "team/src", mode: "read_only" }, MOUNT_EXEC);
144
- await toolHandlers.container_mount_update(resolver, { container: "valkey-ctr", volume: "valkey-data", destination: "/data", mode: "read_write" }, MOUNT_EXEC);
175
+ await toolHandlers.container_mount_update(resolver, {
176
+ container: "valkey-ctr",
177
+ kind: "project",
178
+ project: "team/src",
179
+ mode: "read_only",
180
+ }, MOUNT_EXEC);
181
+ await toolHandlers.container_mount_update(resolver, {
182
+ container: "valkey-ctr",
183
+ volume: "valkey-data",
184
+ destination: "/data",
185
+ mode: "read_write",
186
+ }, MOUNT_EXEC);
145
187
  assert.deepEqual(requests[0], [
146
188
  "updateContainerMount",
147
189
  {
@@ -166,7 +208,12 @@ test("container_mount_update maps the selector and mode", async () => {
166
208
  });
167
209
  test("container_mount_update rejects kinds without a mode", async () => {
168
210
  const { requests, resolver } = mountRequestRecorder();
169
- await assert.rejects(() => toolHandlers.container_mount_update(resolver, { container: "web", kind: "tmpfs", destination: "/mnt", mode: "read_only" }, MOUNT_EXEC), /only project and volume mounts carry a mode; tmpfs mounts cannot be remounted/);
211
+ await assert.rejects(() => toolHandlers.container_mount_update(resolver, {
212
+ container: "web",
213
+ kind: "tmpfs",
214
+ destination: "/mnt",
215
+ mode: "read_only",
216
+ }, MOUNT_EXEC), /only project and volume mounts carry a mode; tmpfs mounts cannot be remounted/);
170
217
  await assert.rejects(() => toolHandlers.container_mount_update(resolver, { container: "web", project: "team", mode: "rw" }, MOUNT_EXEC), /unknown mount mode: rw/);
171
218
  assert.deepEqual(requests, [], "a rejected update must not reach the orchestrator");
172
219
  });
@@ -192,7 +239,12 @@ test("container_mount_add rejects unknown mount kinds and modes", async () => {
192
239
  });
193
240
  test("container_mount_add rejects a destination on a project mount", async () => {
194
241
  const { requests, resolver } = mountRequestRecorder();
195
- await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", kind: "project", project: "team/src", destination: "/custom" }, MOUNT_EXEC), /Project mounts cannot set a destination; "team\/src" always mounts at "\/projects\/team\/src"/);
242
+ await assert.rejects(() => toolHandlers.container_mount_add(resolver, {
243
+ container: "web",
244
+ kind: "project",
245
+ project: "team/src",
246
+ destination: "/custom",
247
+ }, MOUNT_EXEC), /Project mounts cannot set a destination; "team\/src" always mounts at "\/projects\/team\/src"/);
196
248
  await assert.rejects(() => toolHandlers.container_mount_add(resolver, { container: "web", project: "team", destination: "/custom" }, MOUNT_EXEC), /Project mounts cannot set a destination/);
197
249
  assert.deepEqual(requests, [], "a rejected mount must not reach the orchestrator");
198
250
  await toolHandlers.container_mount_add(resolver, { container: "web", kind: "volume", volume: "data", destination: "/data" }, MOUNT_EXEC);
@@ -277,7 +329,11 @@ test("container start maps valid mounts and defaults the mode to read_only", asy
277
329
  { project: "team/src", mode: "read_only" },
278
330
  { project: "team", mode: "read_write" },
279
331
  { kind: "volume", volume: "valkey-data", destination: "/data" },
280
- { kind: "secret", secret: "valkey-tls", destination: "/run/secrets/tls" },
332
+ {
333
+ kind: "secret",
334
+ secret: "valkey-tls",
335
+ destination: "/run/secrets/tls",
336
+ },
281
337
  { kind: "tmpfs", destination: "/scratch" },
282
338
  ],
283
339
  }, MOUNT_EXEC);
@@ -358,8 +414,17 @@ test("container_mount_list returns mount objects", async () => {
358
414
  workspaceSlug: WORKSPACE_ID,
359
415
  containerName: "default",
360
416
  mounts: [
361
- { projectName: "team", mode: "MOUNT_MODE_READ_WRITE", kind: "MOUNT_KIND_PROJECT" },
362
- { volume: "myvol", destination: "/data", mode: "MOUNT_MODE_READ_ONLY", kind: "MOUNT_KIND_VOLUME" },
417
+ {
418
+ projectName: "team",
419
+ mode: "MOUNT_MODE_READ_WRITE",
420
+ kind: "MOUNT_KIND_PROJECT",
421
+ },
422
+ {
423
+ volume: "myvol",
424
+ destination: "/data",
425
+ mode: "MOUNT_MODE_READ_ONLY",
426
+ kind: "MOUNT_KIND_VOLUME",
427
+ },
363
428
  ],
364
429
  },
365
430
  ],
@@ -369,7 +434,12 @@ test("container_mount_list returns mount objects", async () => {
369
434
  const out = await toolHandlers.container_mount_list(resolver, { container: "default" }, exec);
370
435
  assert.deepEqual(out, [
371
436
  { projectName: "team", mode: "read_write", kind: "project" },
372
- { volume: "myvol", destination: "/data", mode: "read_only", kind: "volume" },
437
+ {
438
+ volume: "myvol",
439
+ destination: "/data",
440
+ mode: "read_only",
441
+ kind: "volume",
442
+ },
373
443
  ]);
374
444
  });
375
445
  test("command tools default to the session directory when mounted", async () => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
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 { TOOLS } from "./index.js";
7
+ // Tool parameters are camelCase. The only snake_case names allowed are the ones
8
+ // that intentionally mirror the harness's built-in read/write/edit tools. Proto
9
+ // field names are lower_snake_case and never reach a tool schema: proto-loader
10
+ // projects them to camelCase (`secret_env` -> `secretEnv`, `image_id` ->
11
+ // `imageId`), so an exposed snake_case proto name is a bug.
12
+ const BUILTIN_MIRRORED = new Set([
13
+ "file_path",
14
+ "old_string",
15
+ "new_string",
16
+ "replace_all",
17
+ ]);
18
+ test("tool parameters never expose a proto-style snake_case name", () => {
19
+ for (const tool of TOOLS) {
20
+ for (const name of Object.keys(tool.parameters.properties)) {
21
+ if (name.includes("_")) {
22
+ assert.ok(BUILTIN_MIRRORED.has(name), `${tool.name}.${name} is snake_case but does not mirror a built-in tool`);
23
+ }
24
+ assert.notEqual(name, "secret_env", `${tool.name} must expose secretEnv, not secret_env`);
25
+ assert.notEqual(name, "image_id", `${tool.name} must expose imageId, not image_id`);
26
+ }
27
+ }
28
+ });
@@ -13,20 +13,52 @@ export function outputReader(mode, spill) {
13
13
  if (!Number.isFinite(maxBytes) || maxBytes < 0)
14
14
  return undefined;
15
15
  let total = 0;
16
- let retained = Buffer.alloc(0);
16
+ // Retained chunks, trimmed from the front down to the cap. Appending copies
17
+ // only the incoming chunk instead of re-concatenating the whole window on
18
+ // every append, which was quadratic over a long stream. `head` is the first
19
+ // live index; the prefix is compacted away periodically so dropped buffers do
20
+ // not stay referenced.
21
+ const chunks = [];
22
+ let head = 0;
23
+ let retained = 0;
17
24
  let spillValid = spill !== undefined;
18
25
  return {
19
26
  append(data) {
20
27
  total += data.length;
21
- retained = Buffer.concat([retained, data]).subarray(-maxBytes);
28
+ // A zero cap keeps nothing: `subarray(-0)` is the whole buffer.
29
+ if (maxBytes === 0 || data.length === 0)
30
+ return;
31
+ chunks.push(data);
32
+ retained += data.length;
33
+ let excess = retained - maxBytes;
34
+ while (excess > 0 && head < chunks.length) {
35
+ const first = chunks[head];
36
+ if (first.length <= excess) {
37
+ head++;
38
+ retained -= first.length;
39
+ excess -= first.length;
40
+ }
41
+ else {
42
+ chunks[head] = first.subarray(excess);
43
+ retained -= excess;
44
+ excess = 0;
45
+ }
46
+ }
47
+ if (head > 32 && head * 2 >= chunks.length) {
48
+ chunks.splice(0, head);
49
+ head = 0;
50
+ }
22
51
  },
23
52
  readFrom(offset) {
24
- const start = Math.max(0, total - retained.length);
53
+ const start = Math.max(0, total - retained);
25
54
  const requested = Math.max(0, Number.isFinite(offset) ? offset : 0);
26
55
  const local = Math.max(0, requested - start);
27
56
  const lossy = requested < start;
57
+ const window = head >= chunks.length
58
+ ? Buffer.alloc(0)
59
+ : Buffer.concat(chunks.slice(head), retained);
28
60
  return {
29
- text: retained.subarray(local).toString("utf8"),
61
+ text: window.subarray(local).toString("utf8"),
30
62
  nextOffset: total,
31
63
  lossy,
32
64
  // Advertise the full-stream file only while it can still hold the
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
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 { existsSync, readFileSync } from "node:fs";
7
+ // Every harness dependency must be an exact version: the set moves together
8
+ // with the supported dsh release, and a caret quietly allowed the manifest to
9
+ // drift a release behind the image.
10
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
11
+ const SECTIONS = ["devDependencies", "peerDependencies"];
12
+ function manifest() {
13
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
14
+ }
15
+ test("every @deepseek-ai specifier is an exact version", () => {
16
+ const pkg = manifest();
17
+ for (const section of SECTIONS) {
18
+ for (const [name, specifier] of Object.entries(pkg[section] ?? {})) {
19
+ if (!name.startsWith("@deepseek-ai/"))
20
+ continue;
21
+ assert.match(String(specifier), EXACT_VERSION, `${section}: "${name}": "${specifier}" must be an exact version`);
22
+ }
23
+ }
24
+ });
25
+ test("every peer dependency is declared optional", () => {
26
+ const pkg = manifest();
27
+ for (const name of Object.keys(pkg.peerDependencies ?? {})) {
28
+ assert.equal(pkg.peerDependenciesMeta?.[name]?.optional, true, `peerDependenciesMeta["${name}"].optional must be true`);
29
+ }
30
+ });
31
+ test("every @deepseek-ai/dsh-* specifier matches ARG DSH_VERSION", (context) => {
32
+ const containerfile = new URL("../Containerfile.dsh", import.meta.url);
33
+ if (!existsSync(containerfile)) {
34
+ context.skip("not a source checkout");
35
+ return;
36
+ }
37
+ const match = /^ARG\s+DSH_VERSION=(\S+)/m.exec(readFileSync(containerfile, "utf8"));
38
+ assert.ok(match, "Containerfile.dsh must declare ARG DSH_VERSION");
39
+ const dshVersion = match[1];
40
+ assert.match(dshVersion, EXACT_VERSION, "ARG DSH_VERSION must be exact");
41
+ const pkg = manifest();
42
+ for (const section of SECTIONS) {
43
+ for (const [name, specifier] of Object.entries(pkg[section] ?? {})) {
44
+ if (!name.startsWith("@deepseek-ai/dsh-"))
45
+ continue;
46
+ assert.equal(specifier, dshVersion, `${section}: "${name}": "${specifier}" must match ARG DSH_VERSION (${dshVersion})`);
47
+ }
48
+ }
49
+ });
@@ -4,7 +4,7 @@
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { pathRequestRecorder, WORKSPACE_ID } from "./test-support.js";
7
- import { TOOLS, summarizeArgs, toolHandlers } from "./index.js";
7
+ import { summarizeArgs, toolHandlers, TOOLS } from "./index.js";
8
8
  const EXEC = { agent: { session: { header: { cwd: "/proj" } } } };
9
9
  test("the path tools are registered and approval-gated", () => {
10
10
  for (const name of [
@@ -39,7 +39,9 @@ test("container_path_set persists and applies the whole list", async () => {
39
39
  assert.deepEqual(out, { paths: ["/opt/bin", "/usr/local/bin"] });
40
40
  });
41
41
  test("container_path_add prepends and moves an existing entry to the front", async () => {
42
- const { controlCalls, resolver } = pathRequestRecorder({ paths: ["/a", "/b"] });
42
+ const { controlCalls, resolver } = pathRequestRecorder({
43
+ paths: ["/a", "/b"],
44
+ });
43
45
  const added = await toolHandlers.container_path_add(resolver, { container: "default", path: "/c" }, EXEC);
44
46
  assert.deepEqual(added, { paths: ["/c", "/a", "/b"] });
45
47
  const moved = await toolHandlers.container_path_add(resolver, { container: "default", path: "/b" }, EXEC);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
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 { readFileSync } from "node:fs";
7
+ // The display name the Plugins page shows comes from the package's exported
8
+ // locale metadata. Without the `exports` entry the Host cannot resolve the
9
+ // file, and without the `files` entry a packed install has no file to resolve:
10
+ // both fall back silently to the scoped package name, so both are asserted.
11
+ function localeMeta(language) {
12
+ return JSON.parse(readFileSync(new URL(`../locale/${language}.json`, import.meta.url), "utf8"));
13
+ }
14
+ function manifest() {
15
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
16
+ }
17
+ test("the locale metadata declares the plugin's display name", () => {
18
+ for (const language of ["en", "zh"]) {
19
+ const meta = localeMeta(language).meta;
20
+ assert.equal(meta.title, "dsh-podman", `${language}: meta.title`);
21
+ assert.equal(typeof meta.description, "string", `${language}: meta.description must be a string`);
22
+ assert.notEqual(meta.description.trim(), "", `${language}: meta.description must not be empty`);
23
+ }
24
+ });
25
+ test("the locale metadata is exported and shipped", () => {
26
+ const pkg = manifest();
27
+ assert.equal(pkg.exports["./locale/*.json"], "./locale/*.json", "package.json must export the locale metadata");
28
+ assert.ok(pkg.files.includes("locale/*.json"), "package.json files must ship locale/*.json");
29
+ });
@@ -17,26 +17,52 @@ export function projectNameFromHostPath(projectsRoot, path) {
17
17
  }
18
18
  // hostPathForProjectName is the inverse: the absolute directory a project path
19
19
  // names under the projects root, used to open a directory browser where the
20
- // field's current value lives.
20
+ // field's current value lives. A value that climbs above the root with ".."
21
+ // clamps to the root instead of yielding a path outside it.
21
22
  export function hostPathForProjectName(projectsRoot, project) {
22
23
  const root = stripTrailingSlash(projectsRoot);
23
- const name = stripTrailingSlash(project).replace(/^\/+/, "");
24
- return name === "" ? root : `${root}/${name}`;
24
+ const name = normalizeProjectPath(String(project ?? ""));
25
+ if (root === "" || name === undefined || name === "")
26
+ return root;
27
+ return `${root}/${name}`;
25
28
  }
26
29
  function stripTrailingSlash(value) {
27
30
  return String(value ?? "").replace(/\/+$/, "");
28
31
  }
32
+ // normalizeProjectPath collapses "." and empty segments and resolves ".."
33
+ // lexically. It returns undefined when a ".." would climb above the root, so a
34
+ // caller can clamp rather than build an escaping path.
35
+ function normalizeProjectPath(project) {
36
+ const segments = [];
37
+ for (const segment of project.split("/")) {
38
+ if (segment === "" || segment === ".")
39
+ continue;
40
+ if (segment === "..") {
41
+ if (segments.length === 0)
42
+ return undefined;
43
+ segments.pop();
44
+ continue;
45
+ }
46
+ segments.push(segment);
47
+ }
48
+ return segments.join("/");
49
+ }
29
50
  // confineToRoot clamps a directory to the projects root subtree, so a stale or
30
- // hand-edited project path can never open a browser outside it.
51
+ // hand-edited project path can never open a browser outside it. A ".." inside
52
+ // the root is resolved; one that would escape clamps to the root.
31
53
  export function confineToRoot(root, path) {
32
54
  const normalizedRoot = stripTrailingSlash(root);
33
55
  const normalizedPath = stripTrailingSlash(path);
34
56
  if (normalizedRoot === "" || normalizedPath === normalizedRoot) {
35
57
  return normalizedRoot;
36
58
  }
37
- return normalizedPath.startsWith(`${normalizedRoot}/`)
38
- ? normalizedPath
39
- : normalizedRoot;
59
+ if (!normalizedPath.startsWith(`${normalizedRoot}/`)) {
60
+ return normalizedRoot;
61
+ }
62
+ const name = normalizeProjectPath(normalizedPath.slice(normalizedRoot.length + 1));
63
+ if (name === undefined || name === "")
64
+ return normalizedRoot;
65
+ return `${normalizedRoot}/${name}`;
40
66
  }
41
67
  // parentDirectory is the directory one level above `path`, never above the
42
68
  // projects root.