@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
@@ -8,11 +8,27 @@ import { createFilesystemProvider } from "./index.js";
8
8
  export const WORKSPACE_ID = "2c573001-4171-4900-904b-12a5cc02737a";
9
9
  export class FakeTerminalCall extends EventEmitter {
10
10
  stdinChunks = [];
11
+ resizes = [];
12
+ start;
13
+ closed = false;
14
+ ended = false;
11
15
  write(message) {
16
+ if (message.close) {
17
+ this.closed = true;
18
+ return;
19
+ }
12
20
  if (message.start) {
21
+ this.start = message.start;
13
22
  queueMicrotask(() => this.emit("data", { started: { pid: 42 } }));
14
23
  return;
15
24
  }
25
+ if (message.resize) {
26
+ this.resizes.push({
27
+ rows: message.resize.rows,
28
+ cols: message.resize.cols,
29
+ });
30
+ return;
31
+ }
16
32
  if (message.stdinChunk) {
17
33
  this.stdinChunks.push(Buffer.from(message.stdinChunk));
18
34
  return;
@@ -20,7 +36,12 @@ export class FakeTerminalCall extends EventEmitter {
20
36
  if (message.inspectRequestId !== undefined) {
21
37
  const requestId = message.inspectRequestId;
22
38
  queueMicrotask(() => this.emit("data", {
23
- foreground: { requestId, found: true, processGroupId: 7, inputWaiting: true },
39
+ foreground: {
40
+ requestId,
41
+ found: true,
42
+ processGroupId: 7,
43
+ inputWaiting: true,
44
+ },
24
45
  }));
25
46
  return;
26
47
  }
@@ -31,7 +52,9 @@ export class FakeTerminalCall extends EventEmitter {
31
52
  }));
32
53
  }
33
54
  }
34
- end() { }
55
+ end() {
56
+ this.ended = true;
57
+ }
35
58
  cancel() { }
36
59
  emitStdout(data) {
37
60
  this.emit("data", { stdoutChunk: data });
@@ -98,7 +121,9 @@ export const sessionExec = (name, facts = { mode: "read-only" }) => ({
98
121
  export const presetExec = (name, preset, args, facts = {}) => ({
99
122
  name,
100
123
  arguments: args,
101
- agent: { session: { header: { agentPreset: preset }, facts: { ...facts, preset } } },
124
+ agent: {
125
+ session: { header: { agentPreset: preset }, facts: { ...facts, preset } },
126
+ },
102
127
  });
103
128
  export const MOUNT_TOOLS = [
104
129
  "container_mount_list",
@@ -146,7 +171,11 @@ export const SECRET_BEARING_CONTAINER = {
146
171
  agentSocketPath: `/run/dsh-podman/${WORKSPACE_ID}-default/guest.sock`,
147
172
  agentToken: "super-secret-token",
148
173
  mounts: [
149
- { projectName: "team", mode: "MOUNT_MODE_READ_WRITE", kind: "MOUNT_KIND_PROJECT" },
174
+ {
175
+ projectName: "team",
176
+ mode: "MOUNT_MODE_READ_WRITE",
177
+ kind: "MOUNT_KIND_PROJECT",
178
+ },
150
179
  ],
151
180
  env: { PATH: "/bin", DB_PASSWORD: "hunter2" },
152
181
  secretEnv: { DB_PASS: "db-pass" },
@@ -200,8 +229,9 @@ export function guestFileRecorder(content = "hello", exists = true) {
200
229
  write(message) {
201
230
  if (message.start !== undefined)
202
231
  path = message.start.path;
203
- if (message.dataChunk !== undefined)
232
+ if (message.dataChunk !== undefined) {
204
233
  body += String(message.dataChunk);
234
+ }
205
235
  },
206
236
  end() {
207
237
  writes.push({ path, content: body });
@@ -264,8 +294,9 @@ export function guestExecRecorder(defaultCwd, stdout) {
264
294
  },
265
295
  end() {
266
296
  for (const handler of handlers.data ?? []) {
267
- if (stdout !== undefined)
297
+ if (stdout !== undefined) {
268
298
  handler({ stdoutChunk: Buffer.from(stdout) });
299
+ }
269
300
  handler({ exit: { exitCode: 0, signaled: false } });
270
301
  }
271
302
  },
@@ -381,36 +412,61 @@ export function editProvider(binding) {
381
412
  resolveForPath: async () => binding,
382
413
  });
383
414
  }
415
+ // The guest Exec bidi stream the subprocess provider drives, with the
416
+ // backpressure and failure controls its tests need. `write()` records the start
417
+ // message; `end()` replays the process id, the configured stdout and (unless
418
+ // disabled) the exit message, mirroring a guest that answered immediately.
419
+ export class FakeExecStream extends EventEmitter {
420
+ starts;
421
+ options;
422
+ paused = 0;
423
+ resumed = 0;
424
+ cancelled = false;
425
+ constructor(starts, options) {
426
+ super();
427
+ this.starts = starts;
428
+ this.options = options;
429
+ }
430
+ write(message) {
431
+ this.starts.push(message);
432
+ return true;
433
+ }
434
+ end() {
435
+ this.emitData({ processId: "7" });
436
+ if (this.options.stdout !== undefined) {
437
+ this.emitData({ stdoutChunk: Buffer.from(this.options.stdout) });
438
+ }
439
+ if (this.options.emitExit !== false) {
440
+ this.emitData({
441
+ exit: { exitCode: 0, signaled: false, ...this.options.exit },
442
+ });
443
+ }
444
+ }
445
+ pause() {
446
+ this.paused++;
447
+ }
448
+ resume() {
449
+ this.resumed++;
450
+ }
451
+ cancel() {
452
+ this.cancelled = true;
453
+ }
454
+ emitData(message) {
455
+ this.emit("data", message);
456
+ }
457
+ }
384
458
  // A fake guest for the subprocess provider: exec emits a process id (and
385
459
  // optionally an exit), and the unary signal/delete RPCs are recorded.
386
460
  export function spawnGuest(options = {}) {
387
461
  const signals = [];
388
462
  const starts = [];
389
463
  const deletes = [];
464
+ const streams = [];
390
465
  const guest = {
391
466
  exec: () => {
392
- const handlers = {};
393
- return {
394
- on(event, handler) {
395
- (handlers[event] ??= []).push(handler);
396
- },
397
- write(message) {
398
- starts.push(message);
399
- },
400
- end() {
401
- for (const handler of handlers.data ?? []) {
402
- handler({ processId: "7" });
403
- if (options.stdout !== undefined) {
404
- handler({ stdoutChunk: Buffer.from(options.stdout) });
405
- }
406
- if (options.emitExit !== false) {
407
- handler({
408
- exit: { exitCode: 0, signaled: false, ...options.exit },
409
- });
410
- }
411
- }
412
- },
413
- };
467
+ const stream = new FakeExecStream(starts, options);
468
+ streams.push(stream);
469
+ return stream;
414
470
  },
415
471
  signal: (request, _metadata, callback) => {
416
472
  signals.push(request);
@@ -425,13 +481,19 @@ export function spawnGuest(options = {}) {
425
481
  signals,
426
482
  starts,
427
483
  deletes,
484
+ streams,
485
+ guest,
428
486
  resolver: { resolve: async () => ({ guest, token: "t" }) },
429
487
  };
430
488
  }
431
489
  export const spawnSpec = (overrides = {}) => ({
432
490
  argv: ["sleep", "1"],
433
491
  cwd: "/projects/team",
434
- stdio: { stdin: "ignore", stdout: { maxBytes: 10 }, stderr: { maxBytes: 10 } },
492
+ stdio: {
493
+ stdin: "ignore",
494
+ stdout: { maxBytes: 10 },
495
+ stderr: { maxBytes: 10 },
496
+ },
435
497
  graceMs: 50,
436
498
  ...overrides,
437
499
  });
package/dist/tool-defs.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerMountUpdateParameters, containerPathAddParameters, containerPathRemoveParameters, containerPathSetParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, volumeCreateParameters, volumeListParameters, volumeRemoveParameters } from "./tool-params.js";
4
+ import { containerBashParameters, containerEditParameters, containerExecParameters, containerGlobParameters, containerGrepParameters, containerListParameters, containerMountAddParameters, containerMountListParameters, containerMountRemoveParameters, containerMountUpdateParameters, containerPathAddParameters, containerPathRemoveParameters, containerPathSetParameters, containerReadParameters, containerRecreateParameters, containerRemoveParameters, containerSecretAddParameters, containerSecretRemoveParameters, containerStartParameters, containerWriteParameters, daemonListParameters, daemonLogsParameters, daemonRestartParameters, daemonStartParameters, daemonStopParameters, imageBuildParameters, imageGetParameters, imageListParameters, imageRebuildAllParameters, imageRebuildParameters, imageRemoveParameters, secretCreateParameters, secretListParameters, secretRemoveParameters, volumeCreateParameters, volumeListParameters, volumeRemoveParameters, } from "./tool-params.js";
5
5
  export function defineTool(definition) {
6
6
  return definition;
7
7
  }
@@ -9,6 +9,16 @@ export const toolOutput = {
9
9
  schema: { type: "string" },
10
10
  render: (_args, value) => [{ type: "text", text: value }],
11
11
  };
12
+ // hasSecretEnv reports whether a call attaches secrets to a container. Unlike
13
+ // plain `env`, a secret env var injects a value the caller never sees, so
14
+ // container_start must be approved for it exactly like container_secret_add.
15
+ function hasSecretEnv(args) {
16
+ const secretEnv = args.secretEnv;
17
+ return (typeof secretEnv === "object" &&
18
+ secretEnv !== null &&
19
+ !Array.isArray(secretEnv) &&
20
+ Object.keys(secretEnv).length > 0);
21
+ }
12
22
  export const TOOLS = [
13
23
  { name: "image_list", parameters: imageListParameters },
14
24
  { name: "image_get", parameters: imageGetParameters },
@@ -28,14 +38,19 @@ export const TOOLS = [
28
38
  {
29
39
  name: "container_start",
30
40
  parameters: containerStartParameters,
31
- approvalWhen: (args) => Array.isArray(args.mounts) && args.mounts.length > 0,
41
+ approvalWhen: (args) => (Array.isArray(args.mounts) && args.mounts.length > 0) ||
42
+ hasSecretEnv(args),
32
43
  },
33
44
  {
34
45
  name: "container_recreate",
35
46
  parameters: containerRecreateParameters,
36
47
  approval: true,
37
48
  },
38
- { name: "container_remove", parameters: containerRemoveParameters, approval: true },
49
+ {
50
+ name: "container_remove",
51
+ parameters: containerRemoveParameters,
52
+ approval: true,
53
+ },
39
54
  { name: "container_bash", parameters: containerBashParameters },
40
55
  { name: "container_exec", parameters: containerExecParameters },
41
56
  { name: "container_read", parameters: containerReadParameters },
@@ -122,7 +137,7 @@ export const TOOL_DESCRIPTIONS = {
122
137
  container_read: "Read a file inside a container of the current workspace.",
123
138
  container_write: "Write a file inside a container of the current workspace.",
124
139
  container_edit: "Edit a file inside a container of the current workspace.",
125
- container_glob: "List files inside a container of the current workspace matching a glob pattern. A pattern with no \"/\" matches basenames at any depth. Results are files only, include hidden and ignored files, and exclude VCS metadata directories. Defaults to the session working directory when it is mounted; pass path to override.",
140
+ container_glob: 'List files inside a container of the current workspace matching a glob pattern. A pattern with no "/" matches basenames at any depth. Results are files only, include hidden and ignored files, and exclude VCS metadata directories. Defaults to the session working directory when it is mounted; pass path to override.',
126
141
  container_grep: "Search file contents inside a container of the current workspace. Defaults to the session working directory when it is mounted; pass cwd to override.",
127
142
  container_mount_list: "List the mounts of a container in the current workspace, with the exact kind, project, volume, secret, and destination values to pass to container_mount_remove.",
128
143
  container_mount_add: "Add a mount to a container in the current workspace: a project bind (project, a path under the projects root), a tmpfs, a named volume, or a secret, selected by kind (default project). Recreates the container, terminating its running processes; bind-mounted volume data persists. Requires approval: adding a mount changes the container filesystem view.",
@@ -1,11 +1,12 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { bytesText, currentCwd, fsError, guestCwd, outputLines, resolveToolBinding, runExec, sessionWorkspaceSlug, sliceLines, unaryGuest, } from "./guest-rpc.js";
5
- import { inferMountKind, mountsFromInput, projectMountDestinationReason } from "./mount-input.js";
6
- import { publicContainer, publicDaemon, publicImage, publicMount } from "./public.js";
4
+ import { bytesText, currentCwd, fsError, guestCwd, outputLines, resolveToolBinding, runExec, sessionWorkspaceSlug, streamLines, unaryGuest, } from "./guest-rpc.js";
5
+ import { inferMountKind, mountsFromInput, projectMountDestinationReason, } from "./mount-input.js";
6
+ import { mergeDefaultEnv } from "./container-env.js";
7
+ import { publicContainer, publicDaemon, publicImage, publicMount, } from "./public.js";
7
8
  import { grpc } from "./grpc/runtime-client.js";
8
- import { defaultMountMode, mountKindToProto, mountModeToProto } from "./mount-enums.js";
9
+ import { defaultMountMode, mountKindToProto, mountModeToProto, } from "./mount-enums.js";
9
10
  import { containerNotFound, } from "./workspace-binding.js";
10
11
  // Cap on paths `container_glob` returns, matching the built-in glob tool's
11
12
  // default result limit; `--sort=modified` makes the retained head the newest.
@@ -22,6 +23,18 @@ function failOnSearchError(tool, result) {
22
23
  const detail = result.stderr.trim();
23
24
  throw new Error(detail === "" ? `${tool} search failed (ripgrep exit code 2)` : detail);
24
25
  }
26
+ // withSpillNote points the caller at the guest spill file when a command's
27
+ // output was truncated in memory, so the full stream stays reachable with
28
+ // container_read instead of silently vanishing.
29
+ function withSpillNote(result) {
30
+ const paths = [result.stdoutSpillPath, result.stderrSpillPath].filter((path) => typeof path === "string");
31
+ if (paths.length === 0)
32
+ return result;
33
+ return {
34
+ ...result,
35
+ note: `output was truncated; the full stream is at ${paths.join(", ")} (read it with container_read)`,
36
+ };
37
+ }
25
38
  // The terminal environment the harness's shell executors impose on every
26
39
  // command (`ENV_OVERRIDES` in dsh-bash-local), so a container command sees the
27
40
  // same non-interactive terminal as the built-in `bash`.
@@ -39,7 +52,32 @@ const ENV_OVERRIDES = {
39
52
  // collects it through `ctx.shellEnv`.
40
53
  function managedEnv(ctx, exec, env) {
41
54
  const managed = ctx?.get?.("shellEnv")?.collect?.(exec) ?? {};
42
- return { ...ENV_OVERRIDES, ...env, ...managed };
55
+ return {
56
+ ...ENV_OVERRIDES,
57
+ ...env,
58
+ ...managed,
59
+ };
60
+ }
61
+ // startContainerEnv resolves the environment a `container_start` request
62
+ // carries. The caller's map wins over the defaults; a container that does not
63
+ // exist yet is a creation and gets the defaults alone; one that already exists
64
+ // keeps the environment it has (the orchestrator preserves a stored env when
65
+ // the request omits one, so nothing has to be sent).
66
+ async function startContainerEnv(resolver, slug, input) {
67
+ const defaults = resolver.getConfig().containerEnv;
68
+ if (input.env !== undefined) {
69
+ return mergeDefaultEnv(defaults, input.env);
70
+ }
71
+ if (defaults === undefined || Object.keys(defaults).length === 0) {
72
+ return undefined;
73
+ }
74
+ const result = await resolver.control("listContainers", {});
75
+ const name = input.container === "" ? "default" : input.container;
76
+ const exists = (result.containers ?? []).some((row) => row.workspaceSlug === slug && row.containerName === name);
77
+ if (exists)
78
+ return undefined;
79
+ const env = mergeDefaultEnv(defaults, undefined);
80
+ return Object.keys(env).length === 0 ? undefined : env;
43
81
  }
44
82
  // containerTarget resolves a container-scoped file target through the plugin's
45
83
  // own filesystem provider, so the container file tools share the harness's file
@@ -123,12 +161,14 @@ export const toolHandlers = {
123
161
  },
124
162
  container_start: async (resolver, input, exec) => {
125
163
  const mounts = mountsFromInput(input.mounts, resolver.getConfig().projectsRoot);
164
+ const slug = await sessionWorkspaceSlug(resolver, currentCwd(exec));
165
+ const env = await startContainerEnv(resolver, slug, input);
126
166
  const row = await resolver.control("startContainer", {
127
- workspaceSlug: await sessionWorkspaceSlug(resolver, currentCwd(exec)),
167
+ workspaceSlug: slug,
128
168
  container: input.container,
129
169
  imageId: input.image,
130
170
  ...(mounts === undefined ? {} : { mounts }),
131
- ...(input.env !== undefined ? { env: input.env } : {}),
171
+ ...(env === undefined ? {} : { env }),
132
172
  ...(input.secretEnv !== undefined ? { secretEnv: input.secretEnv } : {}),
133
173
  ...(input.paths !== undefined ? { paths: input.paths } : {}),
134
174
  });
@@ -156,13 +196,13 @@ export const toolHandlers = {
156
196
  },
157
197
  container_bash: async (resolver, input, exec, ctx) => {
158
198
  const sessionCwd = currentCwd(exec);
159
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
160
- return runExec(binding, ["bash", "-c", input.command], guestCwd(input.workdir, sessionCwd, binding), managedEnv(ctx, exec, input.env), input.timeoutMs, identityFromInput(input));
199
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
200
+ return withSpillNote(await runExec(binding, ["bash", "-c", input.command], guestCwd(input.workdir, sessionCwd, binding), managedEnv(ctx, exec, input.env), input.timeoutMs, identityFromInput(input), { signal: exec?.signal }));
161
201
  },
162
202
  container_exec: async (resolver, input, exec, ctx) => {
163
203
  const sessionCwd = currentCwd(exec);
164
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
165
- return runExec(binding, input.argv, guestCwd(input.workdir, sessionCwd, binding), managedEnv(ctx, exec, input.env), input.timeoutMs, identityFromInput(input));
204
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
205
+ return withSpillNote(await runExec(binding, input.argv, guestCwd(input.workdir, sessionCwd, binding), managedEnv(ctx, exec, input.env), input.timeoutMs, identityFromInput(input), { signal: exec?.signal }));
166
206
  },
167
207
  container_read: async (resolver, input, exec, ctx) => {
168
208
  const target = await containerTarget(ctx, input.file_path, input.container, exec);
@@ -178,9 +218,11 @@ export const toolHandlers = {
178
218
  if (info.type !== "file") {
179
219
  throw fsError("FS_NOT_REGULAR_FILE", `cannot read "${target.displayPath}": not a regular file`);
180
220
  }
181
- const content = await ctx.fs.readText(target, exec?.signal);
221
+ // Stream the requested line window instead of buffering the whole file:
222
+ // a read of a few lines from a huge file must not read it all into memory.
223
+ const lines = await streamLines(await ctx.fs.streamText(target, exec?.signal), input.offset, input.limit);
182
224
  ctx.emit("fs/observed", target, { kind: "present", version: info.version }, exec);
183
- return sliceLines(content, input.offset, input.limit);
225
+ return lines;
184
226
  },
185
227
  container_write: async (resolver, input, exec, ctx) => {
186
228
  const target = await containerTarget(ctx, input.file_path, input.container, exec);
@@ -190,7 +232,10 @@ export const toolHandlers = {
190
232
  const intent = await ctx.waterfall("fs/write-intent", target, exec, () => undefined);
191
233
  const content = String(input.content ?? "");
192
234
  const outcome = await ctx.fs.writeText(target, content, intent, exec?.signal);
193
- ctx.emit("fs/observed", target, { kind: "present", version: outcome.version }, exec);
235
+ ctx.emit("fs/observed", target, {
236
+ kind: "present",
237
+ version: outcome.version,
238
+ }, exec);
194
239
  return { bytesWritten: Buffer.byteLength(content, "utf8") };
195
240
  },
196
241
  container_edit: async (resolver, input, exec, ctx) => {
@@ -201,12 +246,15 @@ export const toolHandlers = {
201
246
  newString: input.new_string,
202
247
  replaceAll: input.replace_all === true,
203
248
  }, intent, exec?.signal);
204
- ctx.emit("fs/observed", target, { kind: "present", version: outcome.version }, exec);
249
+ ctx.emit("fs/observed", target, {
250
+ kind: "present",
251
+ version: outcome.version,
252
+ }, exec);
205
253
  return { before: outcome.before, after: outcome.after };
206
254
  },
207
255
  container_glob: async (resolver, input, exec) => {
208
256
  const sessionCwd = currentCwd(exec);
209
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
257
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
210
258
  const argv = [
211
259
  "rg",
212
260
  "--files",
@@ -219,7 +267,7 @@ export const toolHandlers = {
219
267
  `--glob=!**/${name}/**`,
220
268
  ]),
221
269
  ];
222
- const result = await runExec(binding, argv, guestCwd(input.path, sessionCwd, binding));
270
+ const result = await runExec(binding, argv, guestCwd(input.path, sessionCwd, binding), undefined, undefined, undefined, { signal: exec?.signal });
223
271
  failOnSearchError("glob", result);
224
272
  const files = outputLines(result.stdout);
225
273
  const capped = files.length > GLOB_MAX_RESULTS;
@@ -234,7 +282,7 @@ export const toolHandlers = {
234
282
  },
235
283
  container_grep: async (resolver, input, exec) => {
236
284
  const sessionCwd = currentCwd(exec);
237
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
285
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
238
286
  const cwd = guestCwd(undefined, sessionCwd, binding);
239
287
  // The search root: the requested path, else the container's default (the
240
288
  // session workspace when it is mounted). Ripgrep reads stdin instead of the
@@ -247,7 +295,9 @@ export const toolHandlers = {
247
295
  argv.push(input.pattern);
248
296
  if (root !== undefined)
249
297
  argv.push(root);
250
- const result = await runExec(binding, argv, cwd);
298
+ const result = await runExec(binding, argv, cwd, undefined, undefined, undefined, {
299
+ signal: exec?.signal,
300
+ });
251
301
  failOnSearchError("grep", result);
252
302
  return {
253
303
  matches: outputLines(result.stdout),
@@ -356,7 +406,7 @@ export const toolHandlers = {
356
406
  container_path_set: async (resolver, input, exec) => {
357
407
  const sessionCwd = currentCwd(exec);
358
408
  const slug = await sessionWorkspaceSlug(resolver, sessionCwd);
359
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
409
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
360
410
  const paths = Array.isArray(input.paths) ? input.paths.map(String) : [];
361
411
  return {
362
412
  paths: await applyContainerPaths(resolver, binding, slug, input.container, paths),
@@ -365,7 +415,7 @@ export const toolHandlers = {
365
415
  container_path_add: async (resolver, input, exec) => {
366
416
  const sessionCwd = currentCwd(exec);
367
417
  const slug = await sessionWorkspaceSlug(resolver, sessionCwd);
368
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
418
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
369
419
  const current = await containerPathState(binding);
370
420
  // Prepending gives the new path the highest priority; an existing entry is
371
421
  // moved to the front rather than duplicated.
@@ -380,7 +430,7 @@ export const toolHandlers = {
380
430
  container_path_remove: async (resolver, input, exec) => {
381
431
  const sessionCwd = currentCwd(exec);
382
432
  const slug = await sessionWorkspaceSlug(resolver, sessionCwd);
383
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
433
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
384
434
  const current = await containerPathState(binding);
385
435
  if (!current.paths.includes(input.path)) {
386
436
  if (current.defaultPaths.includes(input.path)) {
@@ -440,7 +490,7 @@ export const toolHandlers = {
440
490
  },
441
491
  daemon_start: async (resolver, input, exec) => {
442
492
  const sessionCwd = currentCwd(exec);
443
- const binding = await resolveToolBinding(resolver, sessionCwd, input.container);
493
+ const binding = await resolveToolBinding(resolver, sessionCwd, input.container, exec?.signal);
444
494
  const request = {
445
495
  argv: input.argv,
446
496
  inheritEnv: { value: input.inheritEnv !== false },
@@ -463,12 +513,12 @@ export const toolHandlers = {
463
513
  return publicDaemon(info);
464
514
  },
465
515
  daemon_list: async (resolver, input, exec) => {
466
- const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container);
516
+ const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container, exec?.signal);
467
517
  const result = (await unaryGuest({ binding }, "listDaemons", {}));
468
518
  return (result.daemons ?? []).map(publicDaemon);
469
519
  },
470
520
  daemon_stop: async (resolver, input, exec) => {
471
- const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container);
521
+ const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container, exec?.signal);
472
522
  await daemonCall(input.name, () => unaryGuest({ binding }, "stopDaemon", {
473
523
  name: input.name,
474
524
  signal: input.signal,
@@ -476,12 +526,12 @@ export const toolHandlers = {
476
526
  return { stopped: input.name };
477
527
  },
478
528
  daemon_restart: async (resolver, input, exec) => {
479
- const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container);
529
+ const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container, exec?.signal);
480
530
  const info = await daemonCall(input.name, () => unaryGuest({ binding }, "restartDaemon", { name: input.name }));
481
531
  return publicDaemon(info);
482
532
  },
483
533
  daemon_logs: async (resolver, input, exec) => {
484
- const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container);
534
+ const binding = await resolveToolBinding(resolver, currentCwd(exec), input.container, exec?.signal);
485
535
  const result = (await daemonCall(input.name, () => unaryGuest({ binding }, "daemonLogs", {
486
536
  name: input.name,
487
537
  tailBytes: input.tailBytes,
@@ -504,7 +554,9 @@ async function containerPathState(binding) {
504
554
  const paths = Array.isArray(result?.paths)
505
555
  ? result.paths.map((path) => String(path))
506
556
  : [];
507
- const defaultPath = typeof result?.defaultPath === "string" ? result.defaultPath : "";
557
+ const defaultPath = typeof result?.defaultPath === "string"
558
+ ? result.defaultPath
559
+ : "";
508
560
  return {
509
561
  paths,
510
562
  defaultPaths: defaultPath === "" ? [] : defaultPath.split(":"),
@@ -136,7 +136,11 @@ export const containerStartParameters = {
136
136
  },
137
137
  mounts: mountsParam,
138
138
  env: envParam,
139
- secretEnv: { type: "object", additionalProperties: { type: "string" }, description: "Secret environment variables (env var name to secret short name)." },
139
+ secretEnv: {
140
+ type: "object",
141
+ additionalProperties: { type: "string" },
142
+ description: "Secret environment variables (env var name to secret short name).",
143
+ },
140
144
  paths: pathsParam,
141
145
  },
142
146
  required: ["container"],
@@ -151,7 +155,11 @@ export const containerRecreateParameters = {
151
155
  },
152
156
  mounts: mountsParam,
153
157
  env: envParam,
154
- secretEnv: { type: "object", additionalProperties: { type: "string" }, description: "Secret environment variables (env var name to secret short name)." },
158
+ secretEnv: {
159
+ type: "object",
160
+ additionalProperties: { type: "string" },
161
+ description: "Secret environment variables (env var name to secret short name).",
162
+ },
155
163
  paths: pathsParam,
156
164
  },
157
165
  required: ["container"],
@@ -272,12 +280,16 @@ export const volumeListParameters = {
272
280
  };
273
281
  export const volumeCreateParameters = {
274
282
  type: "object",
275
- properties: { name: { type: "string", description: "Volume name to create." } },
283
+ properties: {
284
+ name: { type: "string", description: "Volume name to create." },
285
+ },
276
286
  required: ["name"],
277
287
  };
278
288
  export const volumeRemoveParameters = {
279
289
  type: "object",
280
- properties: { name: { type: "string", description: "Volume name to remove." } },
290
+ properties: {
291
+ name: { type: "string", description: "Volume name to remove." },
292
+ },
281
293
  required: ["name"],
282
294
  };
283
295
  export const secretListParameters = {
@@ -304,7 +316,9 @@ export const secretCreateParameters = {
304
316
  };
305
317
  export const secretRemoveParameters = {
306
318
  type: "object",
307
- properties: { name: { type: "string", description: "Secret name to remove." } },
319
+ properties: {
320
+ name: { type: "string", description: "Secret name to remove." },
321
+ },
308
322
  required: ["name"],
309
323
  };
310
324
  export const containerSecretAddParameters = {
@@ -0,0 +1,4 @@
1
+ export type VersionState = "ok" | "minor-mismatch" | "major-mismatch";
2
+ export declare function core(value: string): string;
3
+ export declare function major(value: string): number;
4
+ export declare function versionState(plugin: string, orchestrator: string): VersionState;
@@ -0,0 +1,29 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ // core returns the leading x.y.z of a version string, or "" when there is
5
+ // none. A release build is exactly the tag; a build from a later commit carries
6
+ // a git-describe suffix (`1.2.3-4-gabc123`), and a local build may be `dev`.
7
+ export function core(value) {
8
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(value ?? ""));
9
+ return match === null ? "" : `${match[1]}.${match[2]}.${match[3]}`;
10
+ }
11
+ // major returns the major component of a version's core, or -1 when the value
12
+ // has no parseable core.
13
+ export function major(value) {
14
+ const parsed = core(value);
15
+ return parsed === "" ? -1 : Number(parsed.slice(0, parsed.indexOf(".")));
16
+ }
17
+ // versionState mirrors the orchestrator's own compatibility rule, so the card
18
+ // reports exactly what the control plane enforces.
19
+ export function versionState(plugin, orchestrator) {
20
+ const orchestratorCore = core(orchestrator);
21
+ if (orchestratorCore === "")
22
+ return "ok";
23
+ const pluginCore = core(plugin);
24
+ if (pluginCore === "")
25
+ return "major-mismatch";
26
+ if (major(plugin) !== major(orchestrator))
27
+ return "major-mismatch";
28
+ return pluginCore === orchestratorCore ? "ok" : "minor-mismatch";
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
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 { core, major, versionState } from "./version-compat.js";
7
+ test("core keeps the leading x.y.z", () => {
8
+ assert.equal(core("1.2.3"), "1.2.3");
9
+ assert.equal(core("0.2.0-rc.3"), "0.2.0");
10
+ assert.equal(core("1.2.3-4-gabc123"), "1.2.3");
11
+ assert.equal(core("dev"), "");
12
+ assert.equal(core(""), "");
13
+ });
14
+ test("major reports the leading component", () => {
15
+ assert.equal(major("1.2.3"), 1);
16
+ assert.equal(major("0.2.0-rc.3"), 0);
17
+ assert.equal(major("dev"), -1);
18
+ });
19
+ test("versionState mirrors the orchestrator's compatibility rule", () => {
20
+ assert.equal(versionState("1.2.3", "1.2.3"), "ok");
21
+ assert.equal(versionState("1.2.3-4-gabc", "1.2.3"), "ok");
22
+ assert.equal(versionState("1.3.0", "1.2.3"), "minor-mismatch");
23
+ assert.equal(versionState("1.2.4", "1.2.3"), "minor-mismatch");
24
+ assert.equal(versionState("2.0.0", "1.9.0"), "major-mismatch");
25
+ assert.equal(versionState("1.9.0", "2.0.0"), "major-mismatch");
26
+ assert.equal(versionState("1.2.3", "dev"), "ok", "an orchestrator with no comparable version accepts any plugin");
27
+ });
@@ -4,7 +4,7 @@
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
6
  import { VOLUME_TOOLS } from "./test-support.js";
7
- import { TOOLS, toolHandlers } from "./index.js";
7
+ import { toolHandlers, TOOLS } from "./index.js";
8
8
  test("volume tools are registered with the expected schemas", () => {
9
9
  for (const name of VOLUME_TOOLS) {
10
10
  const tool = TOOLS.find((entry) => entry.name === name);