@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
@@ -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 { WORKSPACE_ID, fakeToolContext, guestExecRecorder, guestFileRecorder, secretBearingResolver } from "./test-support.js";
7
- import { TOOLS, podmanRuntimeSection, resolveGuestCwd, resolveGuestPath, toolHandlers, } from "./index.js";
6
+ import { fakeToolContext, guestExecRecorder, guestFileRecorder, secretBearingResolver, WORKSPACE_ID, } from "./test-support.js";
7
+ import { podmanRuntimeSection, resolveGuestCwd, resolveGuestPath, toolHandlers, TOOLS, } from "./index.js";
8
8
  test("container start/recreate/bash accept an env map schema", () => {
9
9
  for (const name of ["container_start", "container_recreate", "container_bash"]) {
10
10
  const tool = TOOLS.find((entry) => entry.name === name);
@@ -96,11 +96,34 @@ test("container tools never expose internal fields in their results", async () =
96
96
  const cases = [
97
97
  [toolHandlers.container_start, { container: "default", image: "img-1" }],
98
98
  [toolHandlers.container_recreate, { container: "default" }],
99
- [toolHandlers.container_mount_add, { container: "default", kind: "volume", volume: "v", destination: "/data" }],
100
- [toolHandlers.container_mount_remove, { container: "default", kind: "volume", volume: "v", destination: "/data" }],
101
- [toolHandlers.container_mount_update, { container: "default", kind: "volume", volume: "v", destination: "/data", mode: "read_only" }],
102
- [toolHandlers.container_secret_add, { container: "default", env: "DB_PASS", secret: "db-pass" }],
103
- [toolHandlers.container_secret_remove, { container: "default", env: "DB_PASS" }],
99
+ [toolHandlers.container_mount_add, {
100
+ container: "default",
101
+ kind: "volume",
102
+ volume: "v",
103
+ destination: "/data",
104
+ }],
105
+ [toolHandlers.container_mount_remove, {
106
+ container: "default",
107
+ kind: "volume",
108
+ volume: "v",
109
+ destination: "/data",
110
+ }],
111
+ [toolHandlers.container_mount_update, {
112
+ container: "default",
113
+ kind: "volume",
114
+ volume: "v",
115
+ destination: "/data",
116
+ mode: "read_only",
117
+ }],
118
+ [toolHandlers.container_secret_add, {
119
+ container: "default",
120
+ env: "DB_PASS",
121
+ secret: "db-pass",
122
+ }],
123
+ [toolHandlers.container_secret_remove, {
124
+ container: "default",
125
+ env: "DB_PASS",
126
+ }],
104
127
  ];
105
128
  for (const [handler, input] of cases) {
106
129
  const out = await handler(secretBearingResolver(), input, exec);
@@ -148,7 +171,11 @@ test("container start and recreate forward PATH additions", async () => {
148
171
  getConfig: () => ({ projectsRoot: "/projects" }),
149
172
  control: async (method, request) => {
150
173
  calls.push([method, request]);
151
- return { containerName: "default", status: "running", paths: ["/opt/bin"] };
174
+ return {
175
+ containerName: "default",
176
+ status: "running",
177
+ paths: ["/opt/bin"],
178
+ };
152
179
  },
153
180
  };
154
181
  const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
@@ -159,6 +186,97 @@ test("container start and recreate forward PATH additions", async () => {
159
186
  assert.deepEqual(calls[1][1].paths, ["/usr/local/bin"]);
160
187
  assert.deepEqual(recreated.paths, ["/opt/bin"]);
161
188
  });
189
+ test("container_start seeds the default environment into a new container", async () => {
190
+ const calls = [];
191
+ const resolver = {
192
+ registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
193
+ getConfig: () => ({
194
+ projectsRoot: "/projects",
195
+ containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
196
+ }),
197
+ control: async (method, request) => {
198
+ calls.push([method, request]);
199
+ if (method === "listContainers")
200
+ return { containers: [] };
201
+ return { containerName: "web", status: "running" };
202
+ },
203
+ };
204
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
205
+ await toolHandlers.container_start(resolver, { container: "web", image: "img-1" }, exec);
206
+ assert.deepEqual(calls.map(([method]) => method), ["listContainers", "startContainer"]);
207
+ const start = calls.find(([method]) => method === "startContainer");
208
+ assert.deepEqual(start[1].env, { GIT_AUTHOR_NAME: "John Doe" });
209
+ });
210
+ test("container_start keeps an existing container's environment", async () => {
211
+ const calls = [];
212
+ const resolver = {
213
+ registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
214
+ getConfig: () => ({
215
+ projectsRoot: "/projects",
216
+ containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
217
+ }),
218
+ control: async (method, request) => {
219
+ calls.push([method, request]);
220
+ if (method === "listContainers") {
221
+ return {
222
+ containers: [
223
+ {
224
+ workspaceSlug: WORKSPACE_ID,
225
+ containerName: "web",
226
+ env: { KEEP: "1" },
227
+ },
228
+ ],
229
+ };
230
+ }
231
+ return { containerName: "web", status: "running" };
232
+ },
233
+ };
234
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
235
+ await toolHandlers.container_start(resolver, { container: "web", image: "img-1" }, exec);
236
+ const start = calls.find(([method]) => method === "startContainer");
237
+ assert.equal("env" in start[1], false, "a stored environment must survive a start");
238
+ });
239
+ test("container_start lets the caller's environment win over the defaults", async () => {
240
+ const calls = [];
241
+ const resolver = {
242
+ registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
243
+ getConfig: () => ({
244
+ projectsRoot: "/projects",
245
+ containerEnv: {
246
+ GIT_AUTHOR_NAME: "John Doe",
247
+ GIT_AUTHOR_EMAIL: "john.doe@git.example",
248
+ },
249
+ }),
250
+ control: async (method, request) => {
251
+ calls.push([method, request]);
252
+ return { containerName: "web", status: "running" };
253
+ },
254
+ };
255
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
256
+ await toolHandlers.container_start(resolver, { container: "web", image: "img-1", env: { GIT_AUTHOR_NAME: "Other" } }, exec);
257
+ assert.deepEqual(calls.map(([method]) => method), ["startContainer"]);
258
+ assert.deepEqual(calls[0][1].env, {
259
+ GIT_AUTHOR_NAME: "Other",
260
+ GIT_AUTHOR_EMAIL: "john.doe@git.example",
261
+ });
262
+ });
263
+ test("container_recreate never injects the default environment", async () => {
264
+ const calls = [];
265
+ const resolver = {
266
+ registry: { resolveByPath: async () => ({ id: WORKSPACE_ID }) },
267
+ getConfig: () => ({
268
+ projectsRoot: "/projects",
269
+ containerEnv: { GIT_AUTHOR_NAME: "John Doe" },
270
+ }),
271
+ control: async (method, request) => {
272
+ calls.push([method, request]);
273
+ return { containerName: "default", status: "running" };
274
+ },
275
+ };
276
+ const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
277
+ await toolHandlers.container_recreate(resolver, { container: "default" }, exec);
278
+ assert.equal("env" in calls[0][1], false, "a recreate stores what it is given");
279
+ });
162
280
  test("resolveGuestPath resolves relative paths and refuses traversal", () => {
163
281
  assert.equal(resolveGuestPath("/abs/file", "/projects/team"), "/abs/file");
164
282
  assert.equal(resolveGuestPath("README.md", "/projects/team"), "/projects/team/README.md");
@@ -207,10 +325,21 @@ test("container_read reports a missing file and records it as absent", async ()
207
325
  test("container_edit requires a unique match unless replace_all is set", async () => {
208
326
  const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
209
327
  const duplicate = guestFileRecorder("a a a");
210
- await assert.rejects(() => toolHandlers.container_edit(duplicate.resolver, { container: "default", file_path: "f", old_string: "a", new_string: "b" }, exec, fakeToolContext(duplicate.resolver)), /matched 3 times/);
328
+ await assert.rejects(() => toolHandlers.container_edit(duplicate.resolver, {
329
+ container: "default",
330
+ file_path: "f",
331
+ old_string: "a",
332
+ new_string: "b",
333
+ }, exec, fakeToolContext(duplicate.resolver)), /matched 3 times/);
211
334
  assert.deepEqual(duplicate.writes, [], "a non-unique match must not be written");
212
335
  const all = guestFileRecorder("a a a");
213
- await toolHandlers.container_edit(all.resolver, { container: "default", file_path: "f", old_string: "a", new_string: "b", replace_all: true }, exec, fakeToolContext(all.resolver));
336
+ await toolHandlers.container_edit(all.resolver, {
337
+ container: "default",
338
+ file_path: "f",
339
+ old_string: "a",
340
+ new_string: "b",
341
+ replace_all: true,
342
+ }, exec, fakeToolContext(all.resolver));
214
343
  assert.equal(all.writes[0].content, "b b b");
215
344
  });
216
345
  test("container command and file tools mirror the built-in arguments", () => {
@@ -249,8 +378,9 @@ test("container_bash enforces timeoutMs", async () => {
249
378
  return {
250
379
  on(event, handler) {
251
380
  (handlers[event] ??= []).push(handler);
252
- if (event === "data")
381
+ if (event === "data") {
253
382
  queueMicrotask(() => handler({ processId: "42" }));
383
+ }
254
384
  },
255
385
  write() { },
256
386
  end() { },
@@ -269,7 +399,12 @@ test("container_bash enforces timeoutMs", async () => {
269
399
  defaultCwd: "/projects/team",
270
400
  }),
271
401
  };
272
- await assert.rejects(() => toolHandlers.container_bash(resolver, { container: "default", command: "sleep", description: "x", timeoutMs: 10 }, exec), /timed out/);
402
+ await assert.rejects(() => toolHandlers.container_bash(resolver, {
403
+ container: "default",
404
+ command: "sleep",
405
+ description: "x",
406
+ timeoutMs: 10,
407
+ }, exec), /timed out/);
273
408
  assert.equal(signaled.processId, "42");
274
409
  assert.equal(signaled.signal, "SIGTERM");
275
410
  });
@@ -314,17 +449,34 @@ test("container_grep resolves its search path like a shell would", async () => {
314
449
  // With a search path, it is relative to the session directory.
315
450
  const plain = guestExecRecorder("/projects/team");
316
451
  await toolHandlers.container_grep(plain.resolver, { container: "default", pattern: "TODO", path: "src" }, exec);
317
- assert.deepEqual(plain.starts[0].argv, ["/usr/bin/rg", "-n", "TODO", "/projects/team/src"]);
452
+ assert.deepEqual(plain.starts[0].argv, [
453
+ "/usr/bin/rg",
454
+ "-n",
455
+ "TODO",
456
+ "/projects/team/src",
457
+ ]);
318
458
  // An include filter maps to ripgrep's --glob.
319
459
  const filtered = guestExecRecorder("/projects/team");
320
460
  await toolHandlers.container_grep(filtered.resolver, { container: "default", pattern: "TODO", path: "src", include: "*.ts" }, exec);
321
- assert.deepEqual(filtered.starts[0].argv, ["/usr/bin/rg", "-n", "--glob", "*.ts", "TODO", "/projects/team/src"]);
461
+ assert.deepEqual(filtered.starts[0].argv, [
462
+ "/usr/bin/rg",
463
+ "-n",
464
+ "--glob",
465
+ "*.ts",
466
+ "TODO",
467
+ "/projects/team/src",
468
+ ]);
322
469
  assert.equal(filtered.starts[0].cwd, "/projects/team");
323
470
  const none = guestExecRecorder("/projects/team");
324
471
  await toolHandlers.container_grep(none.resolver, { container: "default", pattern: "TODO" }, exec);
325
472
  // No path searches the session workspace explicitly: ripgrep would otherwise
326
473
  // read its (non-TTY) stdin instead of the working directory.
327
- assert.deepEqual(none.starts[0].argv, ["/usr/bin/rg", "-n", "TODO", "/projects/team"]);
474
+ assert.deepEqual(none.starts[0].argv, [
475
+ "/usr/bin/rg",
476
+ "-n",
477
+ "TODO",
478
+ "/projects/team",
479
+ ]);
328
480
  assert.equal(none.starts[0].cwd, "/projects/team");
329
481
  });
330
482
  test("container_glob passes the pattern as a glob and scopes the search", async () => {
@@ -360,7 +512,8 @@ test("container_glob passes the pattern as a glob and scopes the search", async
360
512
  });
361
513
  test("container_glob caps its result at 100 files", async () => {
362
514
  const exec = { agent: { session: { header: { cwd: "/projects/team" } } } };
363
- const lines = (count) => Array.from({ length: count }, (_, index) => `file-${index}.ts`).join("\n") + "\n";
515
+ const lines = (count) => Array.from({ length: count }, (_, index) => `file-${index}.ts`).join("\n") +
516
+ "\n";
364
517
  const small = guestExecRecorder("/projects/team", lines(3));
365
518
  const few = (await toolHandlers.container_glob(small.resolver, { container: "default", pattern: "*" }, exec));
366
519
  assert.equal(few.files.length, 3);
@@ -401,7 +554,9 @@ test("podmanRuntimeSection names only the tools the agent has", () => {
401
554
  assert.doesNotMatch(full, /plugin/i);
402
555
  // The Podman operator preset mounts none of them, so the wording must not
403
556
  // name a tool the agent cannot call.
404
- const containerOnly = podmanRuntimeSection({ tools: { get: () => undefined } });
557
+ const containerOnly = podmanRuntimeSection({
558
+ tools: { get: () => undefined },
559
+ });
405
560
  const short = containerOnly.text({ scope: "agent" });
406
561
  for (const tool of ["bash", "read", "write", "edit", "glob", "grep"]) {
407
562
  assert.doesNotMatch(short, new RegExp("`" + tool + "`"));
@@ -416,6 +571,27 @@ test("podmanRuntimeSection names only the tools the agent has", () => {
416
571
  assert.match(short, /daemon_\*/);
417
572
  assert.match(short, /share the host kernel/);
418
573
  });
574
+ test("podmanRuntimeSection guides the model to custom images and named volumes", () => {
575
+ const variants = [
576
+ podmanRuntimeSection({ tools: { get: () => ({}) } }),
577
+ podmanRuntimeSection({ tools: { get: () => undefined } }),
578
+ ];
579
+ for (const section of variants) {
580
+ const text = section.text({ scope: "agent" });
581
+ // Software installs belong in an image, not a running container.
582
+ assert.match(text, /custom image/);
583
+ assert.match(text, /`image_build`/);
584
+ assert.match(text, /`container_start`/);
585
+ assert.match(text, /`container_recreate`/);
586
+ // Persistence belongs in a named volume, not a tmpfs.
587
+ assert.match(text, /named `volume`/);
588
+ assert.match(text, /`tmpfs`/);
589
+ assert.match(text, /\/tmp/);
590
+ assert.match(text, /clears tmpfs contents/);
591
+ // The guidance stays free of plugin lore: the section reads as facts.
592
+ assert.doesNotMatch(text, /plugin/i);
593
+ }
594
+ });
419
595
  test("container_grep fails loudly when ripgrep reports an error", async () => {
420
596
  const guest = {
421
597
  exec: () => {
@@ -427,7 +603,9 @@ test("container_grep fails loudly when ripgrep reports an error", async () => {
427
603
  write() { },
428
604
  end() {
429
605
  for (const handler of handlers.data ?? []) {
430
- handler({ stderrChunk: Buffer.from("rg: error parsing glob '[bad'\n") });
606
+ handler({
607
+ stderrChunk: Buffer.from("rg: error parsing glob '[bad'\n"),
608
+ });
431
609
  handler({ exit: { exitCode: 2, signaled: false } });
432
610
  }
433
611
  },
@@ -4,7 +4,7 @@
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { DAEMON_TOOLS } from "./test-support.js";
7
- import { TOOLS, publicDaemon, toolHandlers } from "./index.js";
7
+ import { publicDaemon, toolHandlers, TOOLS } from "./index.js";
8
8
  test("daemon_start accepts optional uid, gid and groups", () => {
9
9
  const tool = TOOLS.find((entry) => entry.name === "daemon_start");
10
10
  assert.ok(tool, "daemon_start registered");
@@ -69,10 +69,20 @@ test("daemon tools require no approval and are valid object-rooted schemas", ()
69
69
  }
70
70
  });
71
71
  test("publicDaemon omits the exit code while the daemon runs", () => {
72
- const running = publicDaemon({ name: "web", argv: ["sleep", "600"], running: true, exitCode: 0 });
72
+ const running = publicDaemon({
73
+ name: "web",
74
+ argv: ["sleep", "600"],
75
+ running: true,
76
+ exitCode: 0,
77
+ });
73
78
  assert.equal(running.running, true);
74
79
  assert.equal("exitCode" in running, false);
75
- const exited = publicDaemon({ name: "web", argv: ["sleep", "600"], running: false, exitCode: 3 });
80
+ const exited = publicDaemon({
81
+ name: "web",
82
+ argv: ["sleep", "600"],
83
+ running: false,
84
+ exitCode: 3,
85
+ });
76
86
  assert.equal(exited.running, false);
77
87
  assert.equal(exited.exitCode, 3);
78
88
  });
@@ -111,7 +121,11 @@ test("daemon_start returns a daemon info object", async () => {
111
121
  }),
112
122
  };
113
123
  const exec = { agent: { session: { header: { cwd: "/proj" } } } };
114
- const out = await toolHandlers.daemon_start(resolver, { container: "default", argv: ["python3", "-m", "http.server", "8000"], name: "web" }, exec);
124
+ const out = await toolHandlers.daemon_start(resolver, {
125
+ container: "default",
126
+ argv: ["python3", "-m", "http.server", "8000"],
127
+ name: "web",
128
+ }, exec);
115
129
  assert.deepEqual(out, {
116
130
  name: "web",
117
131
  running: true,
@@ -0,0 +1 @@
1
+ export declare function dshVersion(): string;
@@ -0,0 +1,46 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import { createRequire } from "node:module";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ // The harness version this plugin runs inside. Every `@deepseek-ai/dsh-*`
8
+ // package carries the release version, and the dsh app package is linked into
9
+ // the profile's healed node_modules, so it is resolvable from the plugin even
10
+ // though it is not a dependency of it. "" when it cannot be resolved, which the
11
+ // card renders as unknown.
12
+ let cached;
13
+ export function dshVersion() {
14
+ cached ??= resolveDshVersion() ?? "";
15
+ return cached;
16
+ }
17
+ // resolveDshVersion walks the module search paths the way the harness resolves
18
+ // its own bundles, so a package that does not export ./package.json still
19
+ // resolves.
20
+ function resolveDshVersion() {
21
+ let searchPaths;
22
+ try {
23
+ searchPaths =
24
+ createRequire(import.meta.url).resolve.paths("@deepseek-ai/dsh") ?? [];
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ for (const searchPath of searchPaths) {
30
+ if (searchPath === null)
31
+ continue;
32
+ const manifest = join(searchPath, "@deepseek-ai/dsh", "package.json");
33
+ if (!existsSync(manifest))
34
+ continue;
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
37
+ if (typeof parsed.version === "string" && parsed.version !== "") {
38
+ return parsed.version;
39
+ }
40
+ }
41
+ catch {
42
+ // A malformed manifest is not a version source; keep looking.
43
+ }
44
+ }
45
+ return undefined;
46
+ }
@@ -0,0 +1,14 @@
1
+ export interface EnvDraft {
2
+ key: string;
3
+ value: string;
4
+ }
5
+ export interface EnvRow extends EnvDraft {
6
+ pending: boolean;
7
+ }
8
+ export declare function emptyEnvDraft(): EnvDraft;
9
+ export declare function envRows(env: Record<string, string>, draft: EnvDraft | undefined): EnvRow[];
10
+ export declare function envKeyAvailable(env: Record<string, string>, key: string): boolean;
11
+ export declare function commitEnvDraft(env: Record<string, string>, draft: EnvDraft): Record<string, string> | undefined;
12
+ export declare function renameEnvKey(env: Record<string, string>, index: number, key: string): Record<string, string>;
13
+ export declare function setEnvValue(env: Record<string, string>, index: number, value: string): Record<string, string>;
14
+ export declare function removeEnvRow(env: Record<string, string>, index: number): Record<string, string>;
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ export function emptyEnvDraft() {
5
+ return { key: "", value: "" };
6
+ }
7
+ export function envRows(env, draft) {
8
+ const rows = Object.entries(env).map(([key, value]) => ({
9
+ key,
10
+ value,
11
+ pending: false,
12
+ }));
13
+ if (draft !== undefined)
14
+ rows.push({ ...draft, pending: true });
15
+ return rows;
16
+ }
17
+ // Whether a draft's key can become a variable: non-empty and not already used.
18
+ export function envKeyAvailable(env, key) {
19
+ return key !== "" && !Object.prototype.hasOwnProperty.call(env, key);
20
+ }
21
+ // commitEnvDraft appends the draft to the record once its key is usable, or
22
+ // returns undefined so the caller keeps the row open instead of inventing a
23
+ // placeholder variable name.
24
+ export function commitEnvDraft(env, draft) {
25
+ if (!envKeyAvailable(env, draft.key))
26
+ return undefined;
27
+ return { ...env, [draft.key]: draft.value };
28
+ }
29
+ // Every mutation rebuilds the record in order, so a renamed key stays in place
30
+ // instead of jumping to the end.
31
+ export function renameEnvKey(env, index, key) {
32
+ const next = {};
33
+ Object.entries(env).forEach(([current, value], at) => {
34
+ next[at === index ? key : current] = value;
35
+ });
36
+ return next;
37
+ }
38
+ export function setEnvValue(env, index, value) {
39
+ const next = {};
40
+ Object.entries(env).forEach(([key, current], at) => {
41
+ next[key] = at === index ? value : current;
42
+ });
43
+ return next;
44
+ }
45
+ export function removeEnvRow(env, index) {
46
+ const next = {};
47
+ Object.entries(env).forEach(([key, value], at) => {
48
+ if (at !== index)
49
+ next[key] = value;
50
+ });
51
+ return next;
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
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 { commitEnvDraft, emptyEnvDraft, envKeyAvailable, envRows, removeEnvRow, renameEnvKey, setEnvValue, } from "./env-rows.js";
7
+ test("envRows lists the committed entries and then the pending draft", () => {
8
+ assert.deepEqual(envRows({}, undefined), []);
9
+ assert.deepEqual(envRows({ A: "1" }, undefined), [
10
+ { key: "A", value: "1", pending: false },
11
+ ]);
12
+ assert.deepEqual(envRows({ A: "1", B: "2" }, emptyEnvDraft()), [
13
+ { key: "A", value: "1", pending: false },
14
+ { key: "B", value: "2", pending: false },
15
+ { key: "", value: "", pending: true },
16
+ ]);
17
+ });
18
+ test("envKeyAvailable accepts only a new, non-empty key", () => {
19
+ assert.equal(envKeyAvailable({}, ""), false);
20
+ assert.equal(envKeyAvailable({ A: "1" }, "A"), false);
21
+ assert.equal(envKeyAvailable({ A: "1" }, "B"), true);
22
+ // A key inherited from the prototype is not an existing variable.
23
+ assert.equal(envKeyAvailable({}, "toString"), true);
24
+ });
25
+ test("commitEnvDraft refuses an empty or duplicate key", () => {
26
+ assert.equal(commitEnvDraft({}, { key: "", value: "x" }), undefined);
27
+ assert.equal(commitEnvDraft({ A: "1" }, { key: "A", value: "x" }), undefined);
28
+ });
29
+ test("commitEnvDraft appends an accepted key with its value", () => {
30
+ assert.deepEqual(commitEnvDraft({ A: "1" }, { key: "B", value: "2" }), {
31
+ A: "1",
32
+ B: "2",
33
+ });
34
+ assert.deepEqual(commitEnvDraft({}, { key: "B", value: "" }), { B: "" });
35
+ });
36
+ test("env transformations keep row order", () => {
37
+ const env = { A: "1", B: "2", C: "3" };
38
+ assert.deepEqual(renameEnvKey(env, 1, "BB"), { A: "1", BB: "2", C: "3" });
39
+ assert.deepEqual(setEnvValue(env, 2, "33"), { A: "1", B: "2", C: "33" });
40
+ assert.deepEqual(removeEnvRow(env, 0), { B: "2", C: "3" });
41
+ // The input record is never mutated.
42
+ assert.deepEqual(env, { A: "1", B: "2", C: "3" });
43
+ });
@@ -19,5 +19,6 @@ export interface FilesystemProvider {
19
19
  mkdir(target: any, parents?: boolean): Promise<any>;
20
20
  remove(target: any, recursive?: boolean): Promise<any>;
21
21
  editText(target: any, edit: any, expected?: any, signal?: AbortSignal): Promise<any>;
22
+ watch(target: any, changed: (error?: Error) => void, signal?: AbortSignal): Promise<() => Promise<void>>;
22
23
  }
23
24
  export declare function createFilesystemProvider(resolver: WorkspaceResolver): FilesystemProvider;
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { DIFF_BASIS_MAX_BYTES, detectLineEndings, fsError, guestChunks, guestStat, guestStatResponse, guestTextChunks, guestVersion, normalizeLineEndings, readDiffBasis, readGuestText, resolveGuestPath, restoreLineEndings, throwIfAborted, unaryGuest, writeGuestFile, } from "./guest-rpc.js";
4
+ import { detectLineEndings, DIFF_BASIS_MAX_BYTES, fsError, guestChunks, guestStat, guestStatResponse, guestTextChunks, guestVersion, normalizeLineEndings, readDiffBasis, readGuestText, resolveGuestPath, restoreLineEndings, throwIfAborted, unaryGuest, writeGuestFile, } from "./guest-rpc.js";
5
5
  import { isAbsolute, join, resolve as resolvePath } from "node:path";
6
6
  export function createFilesystemProvider(resolver) {
7
7
  return {
@@ -10,10 +10,12 @@ export function createFilesystemProvider(resolver) {
10
10
  const resolved = resolveGuestPath(path, opts?.cwd);
11
11
  // An explicit container targets that container's guest; the harness's own
12
12
  // tools never pass one and keep the session workspace's default.
13
- const container = typeof opts?.container === "string" ? opts.container : "";
13
+ const container = typeof opts?.container === "string"
14
+ ? opts.container
15
+ : "";
14
16
  const binding = container !== "" && container !== "default"
15
- ? await resolver.containerBinding(opts?.cwd, container)
16
- : await resolver.resolveForPath(resolved, opts?.cwd);
17
+ ? await resolver.containerBinding(opts?.cwd, container, opts?.signal)
18
+ : await resolver.resolveForPath(resolved, opts?.cwd, opts?.signal);
17
19
  return {
18
20
  targetKey: resolved,
19
21
  displayPath: resolved,
@@ -55,7 +57,7 @@ export function createFilesystemProvider(resolver) {
55
57
  lstat: async (path, opts, signal) => {
56
58
  throwIfAborted(signal, "lstat");
57
59
  const resolved = resolveGuestPath(path, opts?.cwd);
58
- const binding = await resolver.resolveForPath(resolved, opts?.cwd);
60
+ const binding = await resolver.resolveForPath(resolved, opts?.cwd, signal);
59
61
  const result = await unaryGuest({ binding }, "stat", { path: resolved, noFollow: true }, signal);
60
62
  if (!result?.exists)
61
63
  return undefined;
@@ -90,16 +92,18 @@ export function createFilesystemProvider(resolver) {
90
92
  else if (expected?.kind === "createIfAbsent" && current.exists) {
91
93
  throw fsError("FS_NOT_OBSERVED", `cannot overwrite existing "${target.displayPath}" without reading it first`);
92
94
  }
93
- // The basis is gated on the incoming content, like the local backend:
94
- // either side at/above the limit yields `before: null` and the consumer
95
- // falls back to a whole-file diff. Both sides are LF-normalized so a CRLF
96
- // overwrite does not read as every line changed.
95
+ // The basis is gated on both sides: an incoming or existing file at or
96
+ // above the limit yields `before: null` and the consumer falls back to a
97
+ // whole-file diff, so writing one byte into a huge file does not read the
98
+ // whole file back just for presentation. Both sides are LF-normalized so
99
+ // a CRLF overwrite does not read as every line changed.
97
100
  const rawBasis = current.exists &&
98
- Buffer.byteLength(content, "utf8") < DIFF_BASIS_MAX_BYTES
101
+ Buffer.byteLength(content, "utf8") < DIFF_BASIS_MAX_BYTES &&
102
+ Number(current.size ?? 0) < DIFF_BASIS_MAX_BYTES
99
103
  ? await readDiffBasis(target, signal)
100
104
  : null;
101
105
  const before = rawBasis === null ? null : normalizeLineEndings(rawBasis);
102
- await writeGuestFile({ guest: target.binding.guest, token: target.binding.token }, target.targetKey, content, { create: true, truncate: true }, signal);
106
+ await writeGuestFile(target.binding, target.targetKey, content, { create: true, truncate: true }, signal);
103
107
  const after = await guestStatResponse(target, signal);
104
108
  return {
105
109
  operation: current.exists ? "update" : "create",
@@ -172,7 +176,7 @@ export function createFilesystemProvider(resolver) {
172
176
  throw fsError("FS_AMBIGUOUS_EDIT", `old_string matched ${occurrences} times in "${target.displayPath}"; provide a more specific old_string or set replace_all to true`);
173
177
  }
174
178
  const after = before.split(oldNorm).join(newNorm);
175
- await writeGuestFile({ guest: target.binding.guest, token: target.binding.token }, target.targetKey, restoreLineEndings(after, lineEndings), { create: true, truncate: true }, signal);
179
+ await writeGuestFile(target.binding, target.targetKey, restoreLineEndings(after, lineEndings), { create: true, truncate: true }, signal);
176
180
  const afterInfo = await guestStatResponse(target, signal);
177
181
  return {
178
182
  version: guestVersion(afterInfo),
@@ -180,5 +184,15 @@ export function createFilesystemProvider(resolver) {
180
184
  after,
181
185
  };
182
186
  },
187
+ // Watching would need a watcher inside each workspace's container (inotify
188
+ // in the guest, or host-side polling of every open tree node), and every
189
+ // container here is remote and disposable. Report the seam's typed
190
+ // "unsupported" failure instead, as the harness's SSH provider does: the
191
+ // workspace file tree then refreshes on demand rather than live. `changed`
192
+ // is never called — the promise rejects before a watcher becomes active.
193
+ watch: (_target, _changed, signal) => {
194
+ throwIfAborted(signal, "watch");
195
+ return Promise.reject(fsError("FS_IO_ERROR", "Filesystem watching is not supported by this provider."));
196
+ },
183
197
  };
184
198
  }