@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
package/dist/guest-rpc.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
2
  //
3
3
  // SPDX-License-Identifier: MIT
4
- import { metadata, workspaceSlug } from "./workspace-binding.js";
4
+ import { metadata, workspaceSlug, } from "./workspace-binding.js";
5
+ import { grpc } from "./grpc/runtime-client.js";
6
+ import { discardUnneededSpill, outputReader, SPILL_ROOT, } from "./output-reader.js";
7
+ import { randomUUID } from "node:crypto";
5
8
  import { isAbsolute, resolve as resolvePath } from "node:path";
6
9
  // Resolve a container-side path against the session working directory.
7
10
  // Absolute paths pass through unchanged. The session cwd and the container's
@@ -10,7 +13,9 @@ import { isAbsolute, resolve as resolvePath } from "node:path";
10
13
  function resolveAgainstSession(path, sessionCwd) {
11
14
  if (isAbsolute(path))
12
15
  return path;
13
- const base = typeof sessionCwd === "string" && sessionCwd !== "" ? sessionCwd : undefined;
16
+ const base = typeof sessionCwd === "string" && sessionCwd !== ""
17
+ ? sessionCwd
18
+ : undefined;
14
19
  if (base === undefined) {
15
20
  throw new Error("relative paths need a session working directory; pass an absolute path");
16
21
  }
@@ -110,6 +115,26 @@ function onAbortCancel(call, signal) {
110
115
  signal.addEventListener("abort", cancel, { once: true });
111
116
  return () => signal.removeEventListener("abort", cancel);
112
117
  }
118
+ // isUnauthenticated reports whether a guest call failed because the agent
119
+ // rejected the caller's credential.
120
+ function isUnauthenticated(error) {
121
+ return error?.code === grpc.status.UNAUTHENTICATED;
122
+ }
123
+ // withGuestAuth runs a guest call and, when the agent rejects the credential,
124
+ // refreshes the binding once and retries. A rejection happens in the agent's
125
+ // auth interceptor, before the handler runs, so the retry cannot duplicate a
126
+ // side effect; a second rejection propagates.
127
+ export async function withGuestAuth(binding, run) {
128
+ try {
129
+ return await run(binding.guest, binding.token);
130
+ }
131
+ catch (error) {
132
+ if (!isUnauthenticated(error) || binding.refresh === undefined)
133
+ throw error;
134
+ const refreshed = await binding.refresh();
135
+ return await run(refreshed.guest, refreshed.token);
136
+ }
137
+ }
113
138
  // Stream raw bytes from the guest's ReadFile. A zero/absent length reads to
114
139
  // EOF. Cancelling the signal cancels the call and reports FS_ABORTED.
115
140
  export async function* guestChunks(target, range, signal) {
@@ -121,20 +146,48 @@ export async function* guestChunks(target, range, signal) {
121
146
  if (range?.length !== undefined && range.length > 0) {
122
147
  request.length = range.length;
123
148
  }
124
- const call = target.binding.guest.readFile(request, metadata(target.binding.token));
125
- const detach = onAbortCancel(call, signal);
126
- try {
127
- for await (const chunk of call) {
128
- yield Buffer.from(chunk.data);
149
+ // The read is retried once with a refreshed binding when the agent rejects
150
+ // the credential, but only before any byte was emitted, so a retry can never
151
+ // duplicate output.
152
+ let binding = target.binding;
153
+ for (let attempt = 0;; attempt++) {
154
+ const call = binding.guest.readFile(request, metadata(binding.token));
155
+ const detach = onAbortCancel(call, signal);
156
+ let emitted = false;
157
+ let completed = false;
158
+ try {
159
+ for await (const chunk of call) {
160
+ emitted = true;
161
+ yield Buffer.from(chunk.data);
162
+ }
163
+ completed = true;
164
+ return;
165
+ }
166
+ catch (error) {
167
+ if (signal?.aborted)
168
+ throw fsError("FS_ABORTED", "read aborted");
169
+ if (attempt === 0 &&
170
+ !emitted &&
171
+ isUnauthenticated(error) &&
172
+ binding.refresh !== undefined) {
173
+ binding = await binding.refresh();
174
+ continue;
175
+ }
176
+ throw error;
177
+ }
178
+ finally {
179
+ detach();
180
+ if (!completed) {
181
+ // A consumer that stopped early (a bounded line read) or an error path
182
+ // must not leave the guest reading a file nobody consumes.
183
+ try {
184
+ call.cancel?.();
185
+ }
186
+ catch {
187
+ // The call already finished.
188
+ }
189
+ }
129
190
  }
130
- }
131
- catch (error) {
132
- if (signal?.aborted)
133
- throw fsError("FS_ABORTED", "read aborted");
134
- throw error;
135
- }
136
- finally {
137
- detach();
138
191
  }
139
192
  }
140
193
  // Stream the guest file as decoded UTF-8 text, rejecting binary content (a NUL
@@ -191,12 +244,14 @@ export function guestVersion(result) {
191
244
  }
192
245
  export async function writeGuestFile(binding, path, content, opts, signal) {
193
246
  throwIfAborted(signal, "write");
194
- return new Promise((resolveDone, reject) => {
247
+ return withGuestAuth(binding, (guest, token) => new Promise((resolveDone, reject) => {
195
248
  let detach = () => { };
196
- const call = binding.guest.writeFile(metadata(binding.token), {}, (error, result) => {
249
+ const call = guest.writeFile(metadata(token), {}, (error, result) => {
197
250
  detach();
198
251
  error
199
- ? reject(signal?.aborted ? fsError("FS_ABORTED", "write aborted") : error)
252
+ ? reject(signal?.aborted
253
+ ? fsError("FS_ABORTED", "write aborted")
254
+ : error)
200
255
  : resolveDone(Number(result?.bytesWritten ?? 0));
201
256
  });
202
257
  detach = onAbortCancel(call, signal);
@@ -205,78 +260,195 @@ export async function writeGuestFile(binding, path, content, opts, signal) {
205
260
  });
206
261
  call.write({ dataChunk: Buffer.from(content) });
207
262
  call.end();
208
- });
263
+ }));
209
264
  }
210
265
  export async function readGuestFile(binding, path) {
211
- const chunks = [];
212
- await new Promise((resolveDone, reject) => {
213
- const call = binding.guest.readFile({ path }, metadata(binding.token));
266
+ return withGuestAuth(binding, (guest, token) => new Promise((resolveDone, reject) => {
267
+ const chunks = [];
268
+ const call = guest.readFile({ path }, metadata(token));
214
269
  call.on("data", (chunk) => chunks.push(Buffer.from(chunk.data)));
215
270
  call.on("error", reject);
216
- call.on("end", resolveDone);
217
- });
218
- return Buffer.concat(chunks).toString("utf8");
271
+ call.on("end", () => resolveDone(Buffer.concat(chunks).toString("utf8")));
272
+ }));
219
273
  }
220
274
  const READ_LIMIT = 2000;
221
- export async function runExec(binding, argv, cwd, env, timeoutMs, identity) {
222
- const stdout = [];
223
- const stderr = [];
224
- return new Promise((resolveDone, reject) => {
225
- const stream = binding.guest.exec(metadata(binding.token));
275
+ // In-memory retention per stream for one command. The full stream is also
276
+ // written to a bounded guest spill file, so the plugin never buffers an
277
+ // unbounded command output and the caller can read the rest back with
278
+ // container_read.
279
+ const EXEC_RETAIN_BYTES = 1 << 20;
280
+ const EXEC_SPILL_BYTES = 8 << 20;
281
+ // The grace a cancelled command is given between SIGTERM and SIGKILL, matching
282
+ // the harness's own termination grace.
283
+ const EXEC_KILL_GRACE_MS = 5000;
284
+ export async function runExec(binding, argv, cwd, env, timeoutMs, identity, options = {}) {
285
+ const signal = options.signal;
286
+ throwIfAborted(signal, "exec");
287
+ const maxBytes = options.maxBytes ?? EXEC_RETAIN_BYTES;
288
+ const spillBytes = options.spillBytes ?? EXEC_SPILL_BYTES;
289
+ const configuredGrace = Number(options.killGraceMs);
290
+ const killGraceMs = Number.isFinite(configuredGrace) && configuredGrace > 0
291
+ ? configuredGrace
292
+ : EXEC_KILL_GRACE_MS;
293
+ const stdoutSpec = {
294
+ path: `${SPILL_ROOT}/${randomUUID()}.stdout`,
295
+ maxBytes: spillBytes,
296
+ };
297
+ const stderrSpec = {
298
+ path: `${SPILL_ROOT}/${randomUUID()}.stderr`,
299
+ maxBytes: spillBytes,
300
+ };
301
+ return withGuestAuth(binding, (guest, token) => new Promise((resolveDone, reject) => {
302
+ const stdoutReader = outputReader({ maxBytes }, stdoutSpec);
303
+ const stderrReader = outputReader({ maxBytes }, stderrSpec);
304
+ if (stdoutReader === undefined || stderrReader === undefined) {
305
+ reject(new Error("exec output reader is unavailable"));
306
+ return;
307
+ }
308
+ const stream = guest.exec(metadata(token));
226
309
  let processId;
227
310
  let timer;
228
- const stopTimer = () => {
311
+ let killTimer;
312
+ let settled = false;
313
+ let detach = () => { };
314
+ // settle runs one final action exactly once and tears down the timeout
315
+ // and the abort listener, so a timeout, an abort, and an exit cannot
316
+ // race. The kill timer deliberately outlives settle: it is the
317
+ // escalation for a command that ignored the cancellation's SIGTERM.
318
+ const settle = (finish) => {
319
+ if (settled)
320
+ return;
321
+ settled = true;
229
322
  if (timer !== undefined) {
230
323
  clearTimeout(timer);
231
324
  timer = undefined;
232
325
  }
326
+ detach();
327
+ finish();
328
+ };
329
+ const clearKillTimer = () => {
330
+ if (killTimer !== undefined) {
331
+ clearTimeout(killTimer);
332
+ killTimer = undefined;
333
+ }
334
+ };
335
+ const stopProcess = (sig) => {
336
+ if (processId === undefined)
337
+ return;
338
+ // Best effort: ask the guest to terminate the running process tree.
339
+ unaryGuest({ binding: { guest, token } }, "signal", {
340
+ processId,
341
+ signal: sig,
342
+ }).catch(() => { });
343
+ };
344
+ const cancelStream = () => {
345
+ try {
346
+ stream.cancel?.();
347
+ }
348
+ catch {
349
+ // The stream already ended; nothing to cancel.
350
+ }
351
+ };
352
+ // A cancellation asks the command to stop with SIGTERM, then kills its
353
+ // process group once the grace period elapses without an exit. The
354
+ // stream stays open for that grace so the guest still knows the process;
355
+ // the guest also escalates a cancelled stream to a group SIGKILL, so a
356
+ // lost connection stops the tree too.
357
+ const escalateStop = () => {
358
+ if (processId === undefined) {
359
+ // Nothing to signal yet: cancelling the stream is all that is left.
360
+ cancelStream();
361
+ return;
362
+ }
363
+ stopProcess("SIGTERM");
364
+ if (killTimer !== undefined)
365
+ return;
366
+ killTimer = setTimeout(() => {
367
+ killTimer = undefined;
368
+ stopProcess("SIGKILL");
369
+ cancelStream();
370
+ }, killGraceMs);
371
+ killTimer.unref?.();
372
+ };
373
+ const abortWith = (error) => {
374
+ escalateStop();
375
+ settle(() => reject(error));
233
376
  };
234
377
  if (typeof timeoutMs === "number" && timeoutMs > 0) {
235
378
  timer = setTimeout(() => {
236
379
  timer = undefined;
237
- if (processId !== undefined) {
238
- // Best effort: ask the guest to terminate the running process.
239
- unaryGuest({ binding }, "signal", {
240
- processId,
241
- signal: "SIGTERM",
242
- }).catch(() => { });
243
- }
244
- reject(new Error(`command timed out after ${timeoutMs} ms`));
380
+ abortWith(new Error(`command timed out after ${timeoutMs} ms`));
245
381
  }, timeoutMs);
246
382
  }
383
+ if (signal !== undefined) {
384
+ if (signal.aborted) {
385
+ abortWith(fsError("FS_ABORTED", "exec aborted"));
386
+ return;
387
+ }
388
+ const onAbort = () => abortWith(fsError("FS_ABORTED", "exec aborted"));
389
+ signal.addEventListener("abort", onAbort, { once: true });
390
+ detach = () => signal.removeEventListener("abort", onAbort);
391
+ }
247
392
  stream.on("data", (output) => {
248
393
  if (output.processId)
249
394
  processId = String(output.processId);
250
- if (output.stdoutChunk)
251
- stdout.push(Buffer.from(output.stdoutChunk));
252
- if (output.stderrChunk)
253
- stderr.push(Buffer.from(output.stderrChunk));
395
+ if (output.stdoutChunk) {
396
+ stdoutReader.append(Buffer.from(output.stdoutChunk));
397
+ }
398
+ if (output.stderrChunk) {
399
+ stderrReader.append(Buffer.from(output.stderrChunk));
400
+ }
254
401
  if (output.exit) {
255
- stopTimer();
256
- resolveDone({
402
+ // The command stopped on its own within the grace period: nothing is
403
+ // left to escalate.
404
+ clearKillTimer();
405
+ stdoutReader.setSpillValid(Boolean(output.exit.stdoutSpillValid));
406
+ stderrReader.setSpillValid(Boolean(output.exit.stderrSpillValid));
407
+ const out = stdoutReader.readFrom(0);
408
+ const err = stderrReader.readFrom(0);
409
+ // A spill the in-memory tail already covered is deleted; a valid one
410
+ // holding dropped output stays for the caller to read.
411
+ discardUnneededSpill({ guest, token }, stdoutReader);
412
+ discardUnneededSpill({ guest, token }, stderrReader);
413
+ settle(() => resolveDone({
257
414
  exitCode: output.exit.exitCode,
258
415
  signal: output.exit.signaled ? output.exit.signal : null,
259
- stdout: Buffer.concat(stdout).toString("utf8"),
260
- stderr: Buffer.concat(stderr).toString("utf8"),
261
- });
416
+ stdout: out.text,
417
+ stderr: err.text,
418
+ ...(out.spillPath === undefined
419
+ ? {}
420
+ : { stdoutSpillPath: out.spillPath }),
421
+ ...(err.spillPath === undefined
422
+ ? {}
423
+ : { stderrSpillPath: err.spillPath }),
424
+ }));
262
425
  }
263
426
  });
264
- stream.on("error", (error) => {
265
- stopTimer();
266
- reject(error);
267
- });
427
+ stream.on("error", (error) => settle(() => reject(error)));
428
+ stream.on("end", () =>
429
+ // A stream that ends without an exit message (guest restart, dropped
430
+ // socket) must not leave the caller pending forever.
431
+ settle(() => reject(new Error("exec stream ended before the process exited"))));
268
432
  stream.write({
269
433
  start: {
270
434
  argv: remoteArgv(argv),
271
435
  cwd,
272
436
  env: env ?? {},
273
- ...(identity?.uid !== undefined ? { uid: { value: identity.uid } } : {}),
274
- ...(identity?.gid !== undefined ? { gid: { value: identity.gid } } : {}),
275
- ...(identity?.groups !== undefined ? { groups: identity.groups } : {}),
437
+ ...(identity?.uid !== undefined
438
+ ? { uid: { value: identity.uid } }
439
+ : {}),
440
+ ...(identity?.gid !== undefined
441
+ ? { gid: { value: identity.gid } }
442
+ : {}),
443
+ ...(identity?.groups !== undefined
444
+ ? { groups: identity.groups }
445
+ : {}),
446
+ spillStdout: stdoutSpec,
447
+ spillStderr: stderrSpec,
276
448
  },
277
449
  });
278
450
  stream.end();
279
- });
451
+ }));
280
452
  }
281
453
  // sliceLines returns the requested 1-based line range of a file's content,
282
454
  // defaulting to the first READ_LIMIT lines like the harness's read tool.
@@ -289,6 +461,45 @@ export function sliceLines(content, offset, limit) {
289
461
  : READ_LIMIT;
290
462
  return content.split("\n").slice(start - 1, start - 1 + max).join("\n");
291
463
  }
464
+ // streamLines returns the requested 1-based line window of a text stream
465
+ // without buffering the whole source. It matches sliceLines exactly — the
466
+ // source is split on "\n", so a trailing newline keeps a final empty line —
467
+ // while consuming only as much of the stream as the window needs.
468
+ export async function streamLines(stream, offset, limit) {
469
+ const start = typeof offset === "number" && Number.isInteger(offset) && offset > 0
470
+ ? offset
471
+ : 1;
472
+ const max = typeof limit === "number" && Number.isInteger(limit) && limit > 0
473
+ ? limit
474
+ : READ_LIMIT;
475
+ const end = start - 1 + max;
476
+ const out = [];
477
+ let index = 0;
478
+ let carry = "";
479
+ let filled = false;
480
+ for await (const chunk of stream) {
481
+ carry += chunk;
482
+ let newline = carry.indexOf("\n");
483
+ while (newline >= 0) {
484
+ if (index >= start - 1 && index < end)
485
+ out.push(carry.slice(0, newline));
486
+ index++;
487
+ carry = carry.slice(newline + 1);
488
+ newline = carry.indexOf("\n");
489
+ if (index >= end) {
490
+ filled = true;
491
+ break;
492
+ }
493
+ }
494
+ if (filled)
495
+ break;
496
+ }
497
+ // The segment after the last newline, which split always keeps (possibly
498
+ // empty) when the stream ended before the window was filled.
499
+ if (!filled && index >= start - 1 && index < end)
500
+ out.push(carry);
501
+ return out.join("\n");
502
+ }
292
503
  export function outputLines(text) {
293
504
  return text
294
505
  .split("\n")
@@ -305,14 +516,18 @@ export function currentCwd(exec) {
305
516
  }
306
517
  export async function sessionWorkspaceSlug(resolver, cwd) {
307
518
  const workspace = await resolver.registry?.resolveByPath?.(String(cwd));
308
- if (workspace === undefined)
309
- return "default";
519
+ if (workspace === undefined) {
520
+ // "default" is not a workspace slug, so returning it only produced an
521
+ // opaque orchestrator error downstream and a fabricated container_list
522
+ // row; fail with the same shape resolve() uses instead.
523
+ throw new Error(`cannot resolve a DH workspace without a session working directory (got ${JSON.stringify(cwd)})`);
524
+ }
310
525
  return workspaceSlug(workspace.id);
311
526
  }
312
- export async function resolveToolBinding(resolver, cwd, container) {
527
+ export async function resolveToolBinding(resolver, cwd, container, signal) {
313
528
  if (container === "default")
314
- return resolver.resolve(cwd);
315
- return resolver.containerBinding(cwd, container);
529
+ return resolver.resolve(cwd, signal);
530
+ return resolver.containerBinding(cwd, container, signal);
316
531
  }
317
532
  export async function guestStat(target, signal) {
318
533
  const result = await guestStatResponse(target, signal);
@@ -326,29 +541,31 @@ export async function guestStat(target, signal) {
326
541
  }
327
542
  export function guestStatResponse(target, signal) {
328
543
  throwIfAborted(signal, "stat");
329
- return new Promise((resolveDone, reject) => {
544
+ return withGuestAuth(target.binding, (guest, token) => new Promise((resolveDone, reject) => {
330
545
  let detach = () => { };
331
- const call = target.binding.guest.stat({ path: target.targetKey }, metadata(target.binding.token), (error, value) => {
546
+ const call = guest.stat({ path: target.targetKey }, metadata(token), (error, value) => {
332
547
  detach();
333
548
  error
334
549
  ? reject(signal?.aborted ? fsError("FS_ABORTED", "stat aborted") : error)
335
550
  : resolveDone(value);
336
551
  });
337
552
  detach = onAbortCancel(call, signal);
338
- });
553
+ }));
339
554
  }
340
555
  export async function unaryGuest(target, method, request, signal) {
341
556
  throwIfAborted(signal, method);
342
- return new Promise((resolveDone, reject) => {
557
+ return withGuestAuth(target.binding, (guest, token) => new Promise((resolveDone, reject) => {
343
558
  let detach = () => { };
344
- const call = target.binding.guest[method](request, metadata(target.binding.token), (error, result) => {
559
+ const call = guest[method](request, metadata(token), (error, result) => {
345
560
  detach();
346
561
  error
347
- ? reject(signal?.aborted ? fsError("FS_ABORTED", `${method} aborted`) : error)
562
+ ? reject(signal?.aborted
563
+ ? fsError("FS_ABORTED", `${method} aborted`)
564
+ : error)
348
565
  : resolveDone(result);
349
566
  });
350
567
  detach = onAbortCancel(call, signal);
351
- });
568
+ }));
352
569
  }
353
570
  // globCwd returns the working directory a ripgrep discovery listing must run
354
571
  // in, or undefined to keep the caller's. The harness's glob tool passes its
@@ -359,8 +576,9 @@ export async function unaryGuest(target, method, request, signal) {
359
576
  // the harness renders against the session working directory.
360
577
  export function globCwd(argv, cwd) {
361
578
  const runner = argv[0];
362
- if (runner === undefined || !/(?:^|\/)rg(?:\.exe)?$/.test(runner))
579
+ if (runner === undefined || !/(?:^|\/)rg(?:\.exe)?$/.test(runner)) {
363
580
  return undefined;
581
+ }
364
582
  if (!argv.includes("--files"))
365
583
  return undefined;
366
584
  const separator = argv.indexOf("--");
@@ -370,8 +588,9 @@ export function globCwd(argv, cwd) {
370
588
  if (roots.length !== 1)
371
589
  return undefined;
372
590
  const root = roots[0];
373
- if (typeof cwd !== "string" || cwd === "" || !isAbsolute(root))
591
+ if (typeof cwd !== "string" || cwd === "" || !isAbsolute(root)) {
374
592
  return undefined;
593
+ }
375
594
  return root;
376
595
  }
377
596
  // remoteArgv rewrites an argv for the guest's execution world: the harness's
@@ -0,0 +1 @@
1
+ export {};