@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +12 -0
  2. package/README.zh.md +14 -2
  3. package/cordis.patch.yml +58 -0
  4. package/dist/approval-reasons.d.ts +3 -4
  5. package/dist/approval-reasons.js +40 -49
  6. package/dist/approval-reasons.test.js +43 -15
  7. package/dist/approval.js +27 -10
  8. package/dist/approval.test.js +165 -34
  9. package/dist/card-route.d.ts +3 -2
  10. package/dist/card-route.js +103 -35
  11. package/dist/card-route.test.js +60 -43
  12. package/dist/card-test-support.js +39 -66
  13. package/dist/client/ContainerCard.d.ts +1 -1
  14. package/dist/client/ContainerCard.js +76 -70
  15. package/dist/client/card-protocol.d.ts +3 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +13 -18
  18. package/dist/client/container-card-controller.js +86 -40
  19. package/dist/client/container-card-create-modal.d.ts +2 -2
  20. package/dist/client/container-card-create-modal.js +13 -5
  21. package/dist/client/container-card-default-env.d.ts +10 -0
  22. package/dist/client/container-card-default-env.js +69 -0
  23. package/dist/client/container-card-directory.d.ts +2 -2
  24. package/dist/client/container-card-directory.js +12 -4
  25. package/dist/client/container-card-editors.d.ts +3 -3
  26. package/dist/client/container-card-editors.js +104 -32
  27. package/dist/client/container-card-images.d.ts +4 -4
  28. package/dist/client/container-card-images.js +19 -6
  29. package/dist/client/container-card-paths.d.ts +3 -3
  30. package/dist/client/container-card-paths.js +18 -11
  31. package/dist/client/container-card-row.d.ts +10 -6
  32. package/dist/client/container-card-row.js +82 -13
  33. package/dist/client/container-card-secrets.d.ts +3 -3
  34. package/dist/client/container-card-secrets.js +8 -6
  35. package/dist/client/container-card-shared.d.ts +4 -17
  36. package/dist/client/container-card-shared.js +9 -25
  37. package/dist/client/container-card-styles.d.ts +2 -8
  38. package/dist/client/container-card-styles.js +14 -52
  39. package/dist/client/container-card-volumes.d.ts +2 -2
  40. package/dist/client/container-card-volumes.js +9 -5
  41. package/dist/client/container-card-workspace.d.ts +8 -6
  42. package/dist/client/container-card-workspace.js +9 -3
  43. package/dist/client/directory-picker.js +2 -1
  44. package/dist/client/index.js +13109 -2117
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +88 -38
  47. package/dist/client/podman-terminal-guide.d.ts +11 -0
  48. package/dist/client/podman-terminal-guide.js +208 -0
  49. package/dist/client/podman-terminal-title.d.ts +7 -0
  50. package/dist/client/podman-terminal-title.js +30 -0
  51. package/dist/client/podman-terminal.d.ts +26 -0
  52. package/dist/client/podman-terminal.js +412 -0
  53. package/dist/client/read-only-approval.d.ts +2 -2
  54. package/dist/client/read-only-approval.js +13 -3
  55. package/dist/client/slot-contract.d.ts +0 -11
  56. package/dist/client/terminal-preference.d.ts +26 -0
  57. package/dist/client/terminal-preference.js +67 -0
  58. package/dist/client/terminal-protocol.d.ts +85 -0
  59. package/dist/client/terminal-protocol.js +14 -0
  60. package/dist/client/terminal-tab.d.ts +60 -0
  61. package/dist/client/terminal-tab.js +35 -0
  62. package/dist/client/terminal-targets.d.ts +33 -0
  63. package/dist/client/terminal-targets.js +65 -0
  64. package/dist/client/terminal-titles.d.ts +23 -0
  65. package/dist/client/terminal-titles.js +52 -0
  66. package/dist/client/terminal-transport.d.ts +63 -0
  67. package/dist/client/terminal-transport.js +207 -0
  68. package/dist/client/tool-views.js +215 -49
  69. package/dist/container-env.d.ts +11 -0
  70. package/dist/container-env.js +96 -0
  71. package/dist/container-env.test.d.ts +1 -0
  72. package/dist/container-env.test.js +93 -0
  73. package/dist/containers.test.js +196 -18
  74. package/dist/daemons.test.js +18 -4
  75. package/dist/dsh-version.js +2 -1
  76. package/dist/env-rows.d.ts +14 -0
  77. package/dist/env-rows.js +52 -0
  78. package/dist/env-rows.test.d.ts +1 -0
  79. package/dist/env-rows.test.js +43 -0
  80. package/dist/fs-provider.d.ts +1 -0
  81. package/dist/fs-provider.js +26 -12
  82. package/dist/fs-provider.test.js +51 -10
  83. package/dist/generated/version.d.ts +2 -2
  84. package/dist/generated/version.js +2 -2
  85. package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
  86. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  87. package/dist/grpc/runtime-client.d.ts +3 -1
  88. package/dist/grpc/runtime-client.js +90 -17
  89. package/dist/guest-rpc.d.ts +15 -19
  90. package/dist/guest-rpc.js +289 -70
  91. package/dist/guest-rpc.test.d.ts +1 -0
  92. package/dist/guest-rpc.test.js +241 -0
  93. package/dist/guest-terminal.d.ts +30 -0
  94. package/dist/guest-terminal.js +196 -0
  95. package/dist/images.test.js +10 -3
  96. package/dist/index.d.ts +11 -16
  97. package/dist/index.js +42 -36
  98. package/dist/locales.test.d.ts +1 -0
  99. package/dist/locales.test.js +34 -0
  100. package/dist/misc.test.js +38 -10
  101. package/dist/mount-enums.js +2 -1
  102. package/dist/mount-input.d.ts +2 -0
  103. package/dist/mount-input.js +38 -22
  104. package/dist/mounts.test.js +81 -11
  105. package/dist/naming.test.d.ts +1 -0
  106. package/dist/naming.test.js +28 -0
  107. package/dist/output-reader.js +36 -4
  108. package/dist/package-deps.test.d.ts +1 -0
  109. package/dist/package-deps.test.js +49 -0
  110. package/dist/paths.test.js +4 -2
  111. package/dist/plugin-meta.test.d.ts +1 -0
  112. package/dist/plugin-meta.test.js +29 -0
  113. package/dist/project-path.js +33 -7
  114. package/dist/project-path.test.js +14 -0
  115. package/dist/prompts.d.ts +0 -4
  116. package/dist/prompts.js +15 -84
  117. package/dist/read-only-shell.js +3 -1
  118. package/dist/read-only-shell.test.js +80 -25
  119. package/dist/runtime-client.test.d.ts +1 -0
  120. package/dist/runtime-client.test.js +53 -0
  121. package/dist/secrets.test.js +21 -6
  122. package/dist/settings-commands.test.js +61 -14
  123. package/dist/settings-create.test.js +32 -10
  124. package/dist/settings-default-env.test.d.ts +1 -0
  125. package/dist/settings-default-env.test.js +175 -0
  126. package/dist/settings-mounts.test.js +46 -7
  127. package/dist/settings-schema.d.ts +6 -11
  128. package/dist/settings-schema.js +4 -8
  129. package/dist/settings-schema.test.d.ts +1 -0
  130. package/dist/settings-schema.test.js +35 -0
  131. package/dist/settings-workspaces.test.js +36 -63
  132. package/dist/spill-store.d.ts +1 -0
  133. package/dist/spill-store.js +17 -4
  134. package/dist/spill-store.test.js +31 -8
  135. package/dist/subprocess.d.ts +4 -0
  136. package/dist/subprocess.js +148 -162
  137. package/dist/subprocess.test.js +118 -8
  138. package/dist/terminal-preference.test.d.ts +1 -0
  139. package/dist/terminal-preference.test.js +62 -0
  140. package/dist/terminal-route.d.ts +10 -0
  141. package/dist/terminal-route.js +270 -0
  142. package/dist/terminal-route.test.d.ts +1 -0
  143. package/dist/terminal-route.test.js +265 -0
  144. package/dist/terminal-sessions.d.ts +69 -0
  145. package/dist/terminal-sessions.js +252 -0
  146. package/dist/terminal-shells.d.ts +22 -0
  147. package/dist/terminal-shells.js +99 -0
  148. package/dist/terminal-tab.test.d.ts +1 -0
  149. package/dist/terminal-tab.test.js +58 -0
  150. package/dist/terminal-targets.test.d.ts +1 -0
  151. package/dist/terminal-targets.test.js +43 -0
  152. package/dist/terminal-titles.test.d.ts +1 -0
  153. package/dist/terminal-titles.test.js +36 -0
  154. package/dist/test-support.d.ts +35 -5
  155. package/dist/test-support.js +91 -29
  156. package/dist/tool-defs.js +19 -4
  157. package/dist/tool-handlers.js +80 -28
  158. package/dist/tool-params.js +19 -5
  159. package/dist/volumes.test.js +1 -1
  160. package/dist/workspace-binding.d.ts +18 -9
  161. package/dist/workspace-binding.js +244 -77
  162. package/dist/workspace-binding.test.js +189 -25
  163. package/docs/architecture.md +62 -21
  164. package/docs/architecture.zh.md +57 -13
  165. package/docs/configuration.md +61 -20
  166. package/docs/configuration.zh.md +53 -19
  167. package/docs/development-prompt.md +72 -0
  168. package/docs/development-prompt.zh.md +69 -0
  169. package/docs/development.md +141 -21
  170. package/docs/development.zh.md +125 -17
  171. package/docs/install-dsh-and-dsh-podman.md +21 -8
  172. package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
  173. package/docs/uninstall.md +83 -0
  174. package/docs/uninstall.zh.md +79 -0
  175. package/docs/update.md +120 -0
  176. package/docs/update.zh.md +114 -0
  177. package/docs/usage.md +108 -61
  178. package/docs/usage.zh.md +76 -38
  179. package/locale/en.json +6 -0
  180. package/locale/zh.json +6 -0
  181. package/package.json +58 -25
  182. package/quadlet/dsh-podman-orchestrator.container +46 -0
  183. package/quadlet/dsh.container +35 -0
  184. package/LICENSE.pkg +0 -17103
  185. package/dist/preferences.d.ts +0 -6
  186. package/dist/preferences.js +0 -27
@@ -3,14 +3,13 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { createSpillStore } from "./spill-store.js";
6
+ import { createSpillStore, sessionSegment } from "./spill-store.js";
7
7
  import { guestFileRecorder } from "./test-support.js";
8
8
  const SESSION_ID = "2c573001-4171-4900-904b-12a5cc02737a";
9
+ const SEGMENT = sessionSegment(SESSION_ID);
9
10
  function fakeCtx(sessions) {
10
11
  return {
11
- get: (name) => name === "sessions"
12
- ? { get: (id) => sessions[id] }
13
- : undefined,
12
+ get: (name) => name === "sessions" ? { get: (id) => sessions[id] } : undefined,
14
13
  };
15
14
  }
16
15
  function withSession() {
@@ -22,13 +21,18 @@ test("saveText writes a tool-result spill inside the session container", async (
22
21
  const content = "x".repeat(60000);
23
22
  const ref = await store.saveText({
24
23
  owner: { sessionId: SESSION_ID },
25
- source: { kind: "tool", toolName: "web_fetch", callId: "c1", label: "result" },
24
+ source: {
25
+ kind: "tool",
26
+ toolName: "web_fetch",
27
+ callId: "c1",
28
+ label: "result",
29
+ },
26
30
  suggestedName: "web_fetch.txt",
27
31
  content,
28
32
  });
29
- assert.equal(mkdirs[0], `/tmp/dsh-podman/spill/${SESSION_ID}`);
33
+ assert.equal(mkdirs[0], `/tmp/dsh-podman/spill/${SEGMENT}`);
30
34
  assert.equal(writes.length, 1);
31
- assert.match(writes[0].path, new RegExp(`^/tmp/dsh-podman/spill/${SESSION_ID}/[0-9a-f]{32}-web_fetch\\.txt$`));
35
+ assert.match(writes[0].path, new RegExp(`^/tmp/dsh-podman/spill/${SEGMENT}/[0-9a-f]{32}-web_fetch\\.txt$`));
32
36
  assert.equal(ref.locator, writes[0].path);
33
37
  assert.equal(ref.bytes, content.length);
34
38
  assert.equal(writes[0].content, content, "the full text is persisted verbatim");
@@ -43,10 +47,29 @@ test("saveText never lets the suggested name escape the spill directory", async
43
47
  suggestedName: "../../etc/passwd",
44
48
  content: "x",
45
49
  });
46
- assert.ok(ref.locator.startsWith(`/tmp/dsh-podman/spill/${SESSION_ID}/`));
50
+ assert.ok(ref.locator.startsWith(`/tmp/dsh-podman/spill/${SEGMENT}/`));
47
51
  assert.ok(!ref.locator.includes(".."));
48
52
  assert.equal(writes.length, 1);
49
53
  });
54
+ test("sessionSegment neutralizes path-shaped session ids", () => {
55
+ // A session id is not necessarily a UUID, so it must never survive as a path
56
+ // segment that can traverse or collide.
57
+ for (const id of ["..", ".", "../../etc", "a/b", "session-1", "sess..ion", ""]) {
58
+ const segment = sessionSegment(id);
59
+ assert.notEqual(segment, "");
60
+ assert.notEqual(segment, ".");
61
+ assert.notEqual(segment, "..");
62
+ assert.doesNotMatch(segment, /[^A-Za-z0-9_-]/);
63
+ assert.ok(!segment.includes(".."), `${JSON.stringify(id)} -> ${segment}`);
64
+ }
65
+ // Distinct ids never share a directory, even when their readable prefixes do.
66
+ assert.notEqual(sessionSegment("session-1"), sessionSegment("session_1"));
67
+ // The readable prefix is kept for debugging.
68
+ assert.match(sessionSegment("session-42"), /^session-42-/);
69
+ // A UUID-shaped id stays recognizable.
70
+ assert.match(SEGMENT, /^2c573001-4171-4900-904b-[0-9a-f]+$/);
71
+ assert.equal(sessionSegment(SESSION_ID), SEGMENT);
72
+ });
50
73
  test("saveText rejects when the session has no working directory", async () => {
51
74
  const { resolver } = guestFileRecorder();
52
75
  const store = createSpillStore(fakeCtx({}), resolver);
@@ -1,6 +1,10 @@
1
1
  import { type WorkspaceResolver } from "./workspace-binding.js";
2
2
  export interface SubprocessProvider {
3
3
  resolveExecutable(command: string): Promise<string>;
4
+ terminalEnvironment(signal?: AbortSignal): Promise<{
5
+ platform: "posix";
6
+ defaultShell: string;
7
+ }>;
4
8
  spawn(spec: any): any;
5
9
  spawnTerminal(spec: any): Promise<any>;
6
10
  dispose(): void;
@@ -1,11 +1,12 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { discardUnneededSpill, outputReader, spillTargetFor, splitEnv } from "./output-reader.js";
4
+ import { discardUnneededSpill, outputReader, spillTargetFor, splitEnv, } from "./output-reader.js";
5
+ import { openGuestTerminal } from "./guest-terminal.js";
5
6
  import { globCwd, remoteArgv, unaryGuest } from "./guest-rpc.js";
6
7
  import { metadata } from "./workspace-binding.js";
7
- import { randomUUID } from "node:crypto";
8
8
  import { PassThrough } from "node:stream";
9
+ import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
9
10
  export function createSubprocessProvider(resolver) {
10
11
  // Live termination handles, so service disposal can stop every process this
11
12
  // provider started (the harness's disposal contract).
@@ -17,12 +18,24 @@ export function createSubprocessProvider(resolver) {
17
18
  },
18
19
  // The harness's resolveExecutable carries no cwd, and this provider's
19
20
  // execution world is per-workspace (each workspace is its own container),
20
- // so there is no context in which an executable can be verified or looked
21
- // up. Report every request as unresolvable instead of returning an
22
- // unverified path.
21
+ // so a bare command name cannot be looked up here. An absolute path is
22
+ // returned unchanged: the guest resolves and spawns it inside the target
23
+ // container, which is the only place it can be checked. A bare name is
24
+ // reported in the harness's "not found" form, so shell discovery skips a
25
+ // candidate the images do not carry instead of failing the whole list.
23
26
  resolveExecutable: async (command) => {
24
- throw new Error(`cannot resolve executable ${JSON.stringify(command)}: no workspace context is available`);
27
+ if (typeof command === "string" && command.startsWith("/")) {
28
+ return command;
29
+ }
30
+ throw new SubprocessExecutableNotFoundError(`cannot resolve executable ${JSON.stringify(command)}: no workspace context is available`);
25
31
  },
32
+ // Shell selection happens before a workspace is chosen, so the provider
33
+ // reports the family every container it starts belongs to. `/bin/sh` is the
34
+ // portable default; a configured shell overrides it.
35
+ terminalEnvironment: async (_signal) => ({
36
+ platform: "posix",
37
+ defaultShell: "/bin/sh",
38
+ }),
26
39
  spawn: (spec) => {
27
40
  // A pre-aborted spawn is rejected synchronously with a stable Error,
28
41
  // mirroring the local backend, rather than starting a process that would
@@ -40,8 +53,9 @@ export function createSubprocessProvider(resolver) {
40
53
  if (!Array.isArray(spec.argv) ||
41
54
  spec.argv.length === 0 ||
42
55
  typeof spec.argv[0] !== "string" ||
43
- spec.argv[0] === "")
56
+ spec.argv[0] === "") {
44
57
  throw new Error("argv must contain a program");
58
+ }
45
59
  const stdoutSpill = spillTargetFor(spec.stdio?.stdout, "stdout");
46
60
  const stderrSpill = spillTargetFor(spec.stdio?.stderr, "stderr");
47
61
  const stdoutReader = outputReader(spec.stdio?.stdout, stdoutSpill.spec);
@@ -80,6 +94,36 @@ export function createSubprocessProvider(resolver) {
80
94
  killTimer.unref?.();
81
95
  }
82
96
  };
97
+ let settled = false;
98
+ // A piped consumer that falls behind applies backpressure: the guest
99
+ // stream is paused until every outstanding drain (or close) has fired, so
100
+ // a fast producer cannot grow a PassThrough without bound.
101
+ let pendingDrains = 0;
102
+ const applyBackpressure = (stream, target) => {
103
+ pendingDrains++;
104
+ stream.pause?.();
105
+ let released = false;
106
+ const release = () => {
107
+ if (released)
108
+ return;
109
+ released = true;
110
+ target.removeListener("drain", release);
111
+ target.removeListener("close", release);
112
+ pendingDrains--;
113
+ if (pendingDrains === 0)
114
+ stream.resume?.();
115
+ };
116
+ target.once("drain", release);
117
+ // A destroyed target never drains; its close must release the pause or
118
+ // the exit message would stay stuck behind it.
119
+ target.once("close", release);
120
+ };
121
+ const writeChunk = (stream, target, data) => {
122
+ if (target === undefined)
123
+ return;
124
+ if (!target.write(data))
125
+ applyBackpressure(stream, target);
126
+ };
83
127
  // A ripgrep discovery listing runs from its search root, so a pattern
84
128
  // containing "/" anchors to the path the harness passed; the workspace
85
129
  // binding still resolves from the caller's cwd.
@@ -87,13 +131,43 @@ export function createSubprocessProvider(resolver) {
87
131
  const done = resolver.resolve(spec.cwd).then((binding) => new Promise((resolveDone, reject) => {
88
132
  processBinding = binding;
89
133
  const stream = binding.guest.exec(metadata(binding.token));
134
+ // finish settles the handle exactly once and tears it down however
135
+ // the process ends: an exit message, a stream error, or a stream
136
+ // that ends without one. The output pipes and spill files are
137
+ // resolved on every path, so a failed exec leaves no half-open
138
+ // handle, no hung reader, and no orphaned spill.
139
+ const finish = (error, exit) => {
140
+ if (settled)
141
+ return;
142
+ settled = true;
143
+ exited = true;
144
+ if (killTimer !== undefined) {
145
+ clearTimeout(killTimer);
146
+ killTimer = undefined;
147
+ }
148
+ // A pause applied for backpressure must not outlive the process,
149
+ // or the exit/error message could stay stuck behind it.
150
+ if (pendingDrains > 0) {
151
+ pendingDrains = 0;
152
+ stream.resume?.();
153
+ }
154
+ state.stdout?.end();
155
+ state.stderr?.end();
156
+ discardUnneededSpill(binding, stdoutReader);
157
+ discardUnneededSpill(binding, stderrReader);
158
+ if (error === undefined)
159
+ resolveDone(exit);
160
+ else
161
+ reject(error);
162
+ };
90
163
  stream.on("data", (output) => {
91
164
  if (output.stdoutChunk) {
92
165
  const data = Buffer.from(output.stdoutChunk);
93
166
  stdoutReader?.append(data);
94
- state.stdout?.write(data);
95
- if (spec.stdio?.stdout === "inherit")
167
+ writeChunk(stream, state.stdout, data);
168
+ if (spec.stdio?.stdout === "inherit") {
96
169
  process.stdout.write(data);
170
+ }
97
171
  }
98
172
  if (output.processId) {
99
173
  state.pid = Number(output.processId);
@@ -103,27 +177,35 @@ export function createSubprocessProvider(resolver) {
103
177
  if (output.stderrChunk) {
104
178
  const data = Buffer.from(output.stderrChunk);
105
179
  stderrReader?.append(data);
106
- state.stderr?.write(data);
107
- if (spec.stdio?.stderr === "inherit")
180
+ writeChunk(stream, state.stderr, data);
181
+ if (spec.stdio?.stderr === "inherit") {
108
182
  process.stderr.write(data);
183
+ }
109
184
  }
110
185
  if (output.exit) {
111
- exited = true;
112
- if (killTimer !== undefined)
113
- clearTimeout(killTimer);
114
186
  stdoutReader?.setSpillValid(Boolean(output.exit.stdoutSpillValid));
115
187
  stderrReader?.setSpillValid(Boolean(output.exit.stderrSpillValid));
116
- state.stdout?.end();
117
- state.stderr?.end();
118
- discardUnneededSpill(binding, stdoutReader);
119
- discardUnneededSpill(binding, stderrReader);
120
- resolveDone({
188
+ finish(undefined, {
121
189
  exitCode: output.exit.exitCode,
122
190
  signal: output.exit.signaled ? output.exit.signal : null,
123
191
  });
124
192
  }
125
193
  });
126
- stream.on("error", reject);
194
+ stream.on("error", (error) => {
195
+ // The guest connection failed: stop the process if it is still
196
+ // reachable, then release the caller instead of leaving it
197
+ // pending on a stream that will never carry an exit message.
198
+ terminate();
199
+ finish(error instanceof Error ? error : new Error(String(error)));
200
+ });
201
+ stream.on("end", () => {
202
+ // A stream that ends without an exit message (guest restart,
203
+ // dropped socket) must not leave the caller pending forever.
204
+ if (settled)
205
+ return;
206
+ terminate();
207
+ finish(new Error("exec stream ended before the process exited"));
208
+ });
127
209
  const env = splitEnv(spec.env);
128
210
  stream.write({
129
211
  start: {
@@ -161,6 +243,9 @@ export function createSubprocessProvider(resolver) {
161
243
  void done.finally(() => live.delete(terminate)).catch(() => { });
162
244
  return {
163
245
  ...state,
246
+ // The harness's SubprocessHandle declares a control channel; this
247
+ // provider runs every process over gRPC, so none exists.
248
+ control: undefined,
164
249
  collected: {
165
250
  ...(stdoutReader === undefined ? {} : { stdout: stdoutReader }),
166
251
  ...(stderrReader === undefined ? {} : { stderr: stderrReader }),
@@ -195,162 +280,50 @@ export function createSubprocessProvider(resolver) {
195
280
  if (!Array.isArray(spec.argv) ||
196
281
  spec.argv.length === 0 ||
197
282
  typeof spec.argv[0] !== "string" ||
198
- spec.argv[0] === "")
283
+ spec.argv[0] === "") {
199
284
  throw new Error("argv must contain a program");
285
+ }
200
286
  spec.signal?.throwIfAborted();
201
287
  const binding = await resolver.resolveForPath(spec.cwd, spec.cwd);
202
- const call = binding.guest.terminal(metadata(binding.token));
203
- const output = new PassThrough();
204
- let exited = false;
205
- let terminated = false;
206
- let resolveExit;
207
- let rejectExit;
208
- const done = new Promise((resolveDone, rejectDone) => {
209
- resolveExit = resolveDone;
210
- rejectExit = rejectDone;
211
- });
212
- // A caller that never receives the handle (startup failure) must not
213
- // leave `done` as an unhandled rejection.
214
- done.catch(() => { });
215
- let resolveStarted;
216
- let rejectStarted;
217
- const started = new Promise((resolvePid, rejectPid) => {
218
- resolveStarted = resolvePid;
219
- rejectStarted = rejectPid;
288
+ const terminal = openGuestTerminal(binding, {
289
+ argv: spec.argv,
290
+ cwd: spec.cwd,
291
+ env: spec.env,
292
+ cols: spec.cols,
293
+ rows: spec.rows,
294
+ terminalType: spec.terminalType,
220
295
  });
221
- const pending = new Map();
222
- const failPending = (error) => {
223
- for (const waiter of pending.values())
224
- waiter.reject(error);
225
- pending.clear();
226
- };
227
- call.on("data", (message) => {
228
- if (message.started) {
229
- resolveStarted(Number(message.started.pid));
230
- return;
231
- }
232
- if (message.stdoutChunk) {
233
- output.write(Buffer.from(message.stdoutChunk));
234
- return;
235
- }
236
- if (message.exit) {
237
- exited = true;
238
- output.end();
239
- resolveExit({
240
- exitCode: message.exit.exitCode,
241
- signal: message.exit.signaled ? message.exit.signal : null,
242
- });
243
- return;
244
- }
245
- if (message.foreground) {
246
- const waiter = pending.get(message.foreground.requestId);
247
- if (waiter !== undefined) {
248
- pending.delete(message.foreground.requestId);
249
- waiter.resolve(message.foreground);
250
- }
251
- return;
252
- }
253
- if (message.signalled) {
254
- const waiter = pending.get(message.signalled.requestId);
255
- if (waiter !== undefined) {
256
- pending.delete(message.signalled.requestId);
257
- waiter.resolve(message.signalled);
258
- }
259
- }
260
- });
261
- call.on("error", (error) => {
262
- rejectStarted(error);
263
- if (!exited) {
264
- exited = true;
296
+ const output = new PassThrough();
297
+ terminal.onOutput((chunk) => output.write(chunk));
298
+ // Startup failure rejects here, before the caller receives a handle.
299
+ const pid = await terminal.started;
300
+ const done = terminal.done;
301
+ // The handle's output ends however the stream settles, so a consumer
302
+ // reading it is released on exit exactly as it was on a clean close.
303
+ const endOutput = () => {
304
+ if (!output.writableEnded)
265
305
  output.end();
266
- rejectExit(error);
267
- }
268
- failPending(error);
269
- });
270
- call.on("end", () => {
271
- if (exited)
272
- return;
273
- const error = new Error("terminal stream ended before the process exited");
274
- rejectStarted(error);
275
- exited = true;
276
- output.end();
277
- rejectExit(error);
278
- failPending(error);
279
- });
280
- call.write({
281
- start: {
282
- argv: remoteArgv(spec.argv),
283
- cwd: spec.cwd,
284
- env: spec.env ?? {},
285
- rows: spec.rows,
286
- cols: spec.cols,
287
- },
288
- });
289
- const pid = await started;
290
- const request = (message) => {
291
- const requestId = randomUUID();
292
- return new Promise((resolveWaiter, rejectWaiter) => {
293
- pending.set(requestId, { resolve: resolveWaiter, reject: rejectWaiter });
294
- try {
295
- call.write(message(requestId));
296
- }
297
- catch (error) {
298
- pending.delete(requestId);
299
- rejectWaiter(error);
300
- }
301
- });
302
306
  };
307
+ void done.then(endOutput, endOutput).catch(() => { });
303
308
  const handle = {
304
309
  pid,
305
310
  output,
306
311
  done,
307
312
  write: async (data) => {
308
- if (exited)
309
- throw new Error("terminal has exited");
310
- call.write({ stdinChunk: Buffer.from(data) });
311
- },
312
- inspectForeground: async () => {
313
- if (exited)
314
- return undefined;
315
- const response = await request((requestId) => ({
316
- inspectRequestId: requestId,
317
- }));
318
- return response.found
319
- ? {
320
- processGroupId: response.processGroupId,
321
- inputWaiting: response.inputWaiting,
322
- }
323
- : undefined;
313
+ terminal.write(data);
324
314
  },
325
- signalForeground: async (signal) => {
326
- if (exited)
327
- throw new Error("terminal has exited");
328
- const response = await request((requestId) => ({
329
- signal: { signal, requestId },
330
- }));
331
- if (!response.found)
332
- throw new Error(`no foreground process group to signal ${signal}`);
333
- return response.processGroupId;
315
+ resize: async (cols, rows) => {
316
+ terminal.resize(cols, rows);
334
317
  },
318
+ inspectActivity: async () => ({
319
+ state: "unknown",
320
+ revision: terminal.activityRevision(),
321
+ }),
322
+ inspectForeground: async () => terminal.inspectForeground(),
323
+ signalForeground: async (signal) => terminal.signalForeground(signal),
335
324
  terminate: async () => {
336
- if (terminated)
337
- return;
338
- terminated = true;
339
- try {
340
- call.write({ close: true });
341
- call.end();
342
- }
343
- catch {
344
- // A finished call cannot be closed again; nothing to do.
345
- }
346
- try {
347
- await done;
348
- }
349
- catch {
350
- // Termination is idempotent and never rejects.
351
- }
352
- if (!output.writableEnded)
353
- output.end();
325
+ await terminal.terminate();
326
+ endOutput();
354
327
  },
355
328
  };
356
329
  const onAbort = () => {
@@ -362,6 +335,19 @@ export function createSubprocessProvider(resolver) {
362
335
  else
363
336
  spec.signal.addEventListener("abort", onAbort, { once: true });
364
337
  }
338
+ // Register the terminal with the provider's live handles, so disposing
339
+ // the service stops a terminal the caller never closed. Termination is
340
+ // idempotent and never rejects.
341
+ const stop = () => {
342
+ try {
343
+ void handle.terminate();
344
+ }
345
+ catch {
346
+ // Terminal teardown is best-effort.
347
+ }
348
+ };
349
+ live.add(stop);
350
+ void done.finally(() => live.delete(stop)).catch(() => { });
365
351
  return handle;
366
352
  },
367
353
  };
@@ -3,8 +3,9 @@
3
3
  // SPDX-License-Identifier: MIT
4
4
  import test from "node:test";
5
5
  import assert from "node:assert/strict";
6
- import { FakeTerminalCall, fakeTerminalResolver, spawnGuest, spawnSpec } from "./test-support.js";
7
- import { createSubprocessProvider, globCwd, outputReader, remoteArgv } from "./index.js";
6
+ import { FakeTerminalCall, fakeTerminalResolver, spawnGuest, spawnSpec, } from "./test-support.js";
7
+ import { createSubprocessProvider, globCwd, outputReader, remoteArgv, } from "./index.js";
8
+ import { SubprocessExecutableNotFoundError } from "@deepseek-ai/dsh-subprocess";
8
9
  test("globCwd runs a discovery listing from its absolute search root", () => {
9
10
  assert.equal(globCwd(["rg", "--files", "--glob=sub/b.txt", "--", "/tmp/globtest"], "/projects/team"), "/tmp/globtest");
10
11
  assert.equal(globCwd(["rg", "--files", "--glob=a", "--", "sub"], "/projects/team"), undefined, "a relative root keeps the caller's cwd");
@@ -102,6 +103,26 @@ test("outputReader drops bytes beyond the retention window", () => {
102
103
  assert.equal(read.nextOffset, 10);
103
104
  assert.equal(read.lossy, true);
104
105
  });
106
+ test("outputReader with a zero cap retains nothing", () => {
107
+ const spill = { path: "/tmp/dsh-podman/x.stdout", maxBytes: 64 };
108
+ const reader = outputReader({ maxBytes: 0 }, spill);
109
+ reader.append(Buffer.from("abcdef"));
110
+ // `subarray(-0)` is the whole buffer, so a zero cap must be special-cased.
111
+ assert.equal(reader.readFrom(0).text, "");
112
+ assert.equal(reader.readFrom(0).lossy, true);
113
+ assert.equal(reader.spillNeeded, true);
114
+ assert.equal(reader.readFrom(0).spillPath, spill.path);
115
+ });
116
+ test("outputReader keeps the exact tail across many small chunks", () => {
117
+ const reader = outputReader({ maxBytes: 4 });
118
+ for (const character of "0123456789") {
119
+ reader.append(Buffer.from(character));
120
+ }
121
+ const read = reader.readFrom(0);
122
+ assert.equal(read.text, "6789");
123
+ assert.equal(read.nextOffset, 10);
124
+ assert.equal(read.lossy, true);
125
+ });
105
126
  test("outputReader honors in-window offsets", () => {
106
127
  const reader = outputReader({ maxBytes: 5 });
107
128
  reader.append(Buffer.from("abcdefghij"));
@@ -112,12 +133,22 @@ test("outputReader honors in-window offsets", () => {
112
133
  assert.equal(pastEnd.text, "");
113
134
  assert.equal(pastEnd.lossy, false);
114
135
  });
115
- test("resolveExecutable rejects every request without a workspace context", async () => {
136
+ test("resolveExecutable returns absolute paths and reports bare names as not found", async () => {
116
137
  const provider = createSubprocessProvider({});
117
- await assert.rejects(() => provider.resolveExecutable(""));
118
- await assert.rejects(() => provider.resolveExecutable("bin/tool"));
119
- await assert.rejects(() => provider.resolveExecutable("ls"));
120
- await assert.rejects(() => provider.resolveExecutable("/usr/bin/ls"));
138
+ assert.equal(await provider.resolveExecutable("/usr/bin/ls"), "/usr/bin/ls");
139
+ assert.equal(await provider.resolveExecutable("/bin/sh"), "/bin/sh");
140
+ // The harness treats only this class as "not installed", so shell discovery
141
+ // skips a candidate the images do not carry instead of failing.
142
+ for (const command of ["", "bin/tool", "ls", "zsh"]) {
143
+ await assert.rejects(() => provider.resolveExecutable(command), SubprocessExecutableNotFoundError);
144
+ }
145
+ });
146
+ test("terminalEnvironment reports the container's shell family", async () => {
147
+ const provider = createSubprocessProvider({});
148
+ assert.deepEqual(await provider.terminalEnvironment(), {
149
+ platform: "posix",
150
+ defaultShell: "/bin/sh",
151
+ });
121
152
  });
122
153
  test("spawnTerminal drives the guest terminal stream", async () => {
123
154
  const fake = new FakeTerminalCall();
@@ -127,15 +158,22 @@ test("spawnTerminal drives the guest terminal stream", async () => {
127
158
  cwd: "/projects/team",
128
159
  rows: 24,
129
160
  cols: 80,
161
+ terminalType: "xterm-256color",
130
162
  graceMs: 1000,
131
163
  });
132
164
  assert.equal(handle.pid, 42);
165
+ assert.equal(fake.start.env.TERM, "xterm-256color");
133
166
  const chunks = [];
134
167
  handle.output.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
135
168
  const outputEnded = new Promise((resolve) => handle.output.on("end", resolve));
136
169
  fake.emitStdout(Buffer.from("hello"));
137
170
  await handle.write("ls\n");
138
171
  assert.deepEqual(fake.stdinChunks.map((chunk) => chunk.toString("utf8")), ["ls\n"]);
172
+ await handle.resize(100, 40);
173
+ assert.deepEqual(fake.resizes, [{ rows: 40, cols: 100 }]);
174
+ const activity = await handle.inspectActivity();
175
+ assert.equal(activity.state, "unknown");
176
+ assert.ok(activity.revision > 0, "observed activity must advance the revision");
139
177
  assert.deepEqual(await handle.inspectForeground(), {
140
178
  processGroupId: 7,
141
179
  inputWaiting: true,
@@ -149,7 +187,12 @@ test("spawnTerminal drives the guest terminal stream", async () => {
149
187
  });
150
188
  test("spawnTerminal rejects an empty argv", async () => {
151
189
  const provider = createSubprocessProvider(fakeTerminalResolver(new FakeTerminalCall()));
152
- await assert.rejects(() => provider.spawnTerminal({ argv: [], cwd: "/projects/team", rows: 24, cols: 80 }), /argv must contain a program/);
190
+ await assert.rejects(() => provider.spawnTerminal({
191
+ argv: [],
192
+ cwd: "/projects/team",
193
+ rows: 24,
194
+ cols: 80,
195
+ }), /argv must contain a program/);
153
196
  });
154
197
  test("subprocess provider terminates live processes on dispose", async () => {
155
198
  const { signals, resolver } = spawnGuest({ emitExit: false });
@@ -232,3 +275,70 @@ test("subprocess provider keeps a spill that holds dropped output", async () =>
232
275
  assert.equal(read.lossy, true);
233
276
  assert.equal(read.spillPath, fake.starts[0].start.spillStdout.path);
234
277
  });
278
+ test("subprocess provider tears down a failed exec stream", async () => {
279
+ const fake = spawnGuest({ emitExit: false });
280
+ const provider = createSubprocessProvider(fake.resolver);
281
+ const handle = provider.spawn(spawnSpec({
282
+ stdio: {
283
+ stdin: "ignore",
284
+ stdout: "pipe",
285
+ stderr: { maxBytes: 4, spill: { maxBytes: 64 } },
286
+ },
287
+ }));
288
+ await new Promise((resolve) => setImmediate(resolve));
289
+ const failed = assert.rejects(handle.done, /guest vanished/);
290
+ // A paused PassThrough only emits "end" once a consumer reads it to EOF.
291
+ handle.stdout.resume();
292
+ const outputEnded = new Promise((resolve) => handle.stdout.on("end", () => resolve()));
293
+ fake.streams[0].emit("error", new Error("guest vanished"));
294
+ await outputEnded;
295
+ await failed;
296
+ assert.ok(fake.signals.some((signal) => signal.signal === "SIGTERM"), "the process is asked to stop when its stream fails");
297
+ const spill = fake.starts[0].start.spillStderr.path;
298
+ assert.ok(fake.deletes.some((request) => request.path === spill), "the spill is deleted on the failure path");
299
+ });
300
+ test("subprocess provider rejects a stream that ends without an exit", async () => {
301
+ const fake = spawnGuest({ emitExit: false });
302
+ const provider = createSubprocessProvider(fake.resolver);
303
+ const handle = provider.spawn(spawnSpec());
304
+ await new Promise((resolve) => setImmediate(resolve));
305
+ const failed = assert.rejects(handle.done, /ended before the process exited/);
306
+ fake.streams[0].emit("end");
307
+ await failed;
308
+ assert.ok(fake.signals.some((signal) => signal.signal === "SIGTERM"), "a lost stream still asks the process to stop");
309
+ });
310
+ test("subprocess provider pauses a piped stream while the consumer is behind", async () => {
311
+ const chunk = "x".repeat(64 * 1024);
312
+ const fake = spawnGuest({ emitExit: false, stdout: chunk });
313
+ const provider = createSubprocessProvider(fake.resolver);
314
+ const handle = provider.spawn(spawnSpec({ stdio: { stdin: "ignore", stdout: "pipe", stderr: "pipe" } }));
315
+ await new Promise((resolve) => setImmediate(resolve));
316
+ const stream = fake.streams[0];
317
+ assert.ok(stream.paused > 0, "a full PassThrough pauses the guest stream");
318
+ const chunks = [];
319
+ await new Promise((resolve) => handle.stdout.on("data", (data) => {
320
+ chunks.push(Buffer.from(data));
321
+ if (Buffer.concat(chunks).length >= chunk.length)
322
+ resolve();
323
+ }));
324
+ await new Promise((resolve) => setImmediate(resolve));
325
+ assert.ok(stream.resumed > 0, "draining the consumer resumes the stream");
326
+ const exited = handle.done;
327
+ stream.emitData({ exit: { exitCode: 0, signaled: false } });
328
+ assert.deepEqual(await exited, { exitCode: 0, signal: null });
329
+ });
330
+ test("subprocess provider disposes a live terminal", async () => {
331
+ const fake = new FakeTerminalCall();
332
+ const provider = createSubprocessProvider(fakeTerminalResolver(fake));
333
+ const handle = await provider.spawnTerminal({
334
+ argv: ["bash"],
335
+ cwd: "/projects/team",
336
+ rows: 24,
337
+ cols: 80,
338
+ graceMs: 1000,
339
+ });
340
+ assert.equal(handle.pid, 42);
341
+ provider.dispose();
342
+ assert.equal(fake.closed, true, "disposal closes the terminal");
343
+ assert.equal(fake.ended, true, "disposal ends the terminal stream");
344
+ });
@@ -0,0 +1 @@
1
+ export {};