@exagone313/dsh-podman 0.2.0-rc.2
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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/README.zh.md +41 -0
- package/cordis.patch.yml +17 -0
- package/dist/approval-reasons.d.ts +129 -0
- package/dist/approval-reasons.js +253 -0
- package/dist/approval-reasons.test.d.ts +1 -0
- package/dist/approval-reasons.test.js +115 -0
- package/dist/approval.d.ts +29 -0
- package/dist/approval.js +412 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/approval.test.js +462 -0
- package/dist/card-route.d.ts +15 -0
- package/dist/card-route.js +478 -0
- package/dist/card-route.test.d.ts +1 -0
- package/dist/card-route.test.js +181 -0
- package/dist/card-test-support.d.ts +15 -0
- package/dist/card-test-support.js +160 -0
- package/dist/client/ContainerCard.d.ts +6 -0
- package/dist/client/ContainerCard.js +79 -0
- package/dist/client/card-client.d.ts +6 -0
- package/dist/client/card-client.js +47 -0
- package/dist/client/card-protocol.d.ts +101 -0
- package/dist/client/card-protocol.js +10 -0
- package/dist/client/container-card-caches.d.ts +10 -0
- package/dist/client/container-card-caches.js +31 -0
- package/dist/client/container-card-controller.d.ts +107 -0
- package/dist/client/container-card-controller.js +207 -0
- package/dist/client/container-card-create-modal.d.ts +24 -0
- package/dist/client/container-card-create-modal.js +109 -0
- package/dist/client/container-card-directory-styles.d.ts +5 -0
- package/dist/client/container-card-directory-styles.js +246 -0
- package/dist/client/container-card-directory.d.ts +12 -0
- package/dist/client/container-card-directory.js +144 -0
- package/dist/client/container-card-editors.d.ts +30 -0
- package/dist/client/container-card-editors.js +116 -0
- package/dist/client/container-card-images.d.ts +37 -0
- package/dist/client/container-card-images.js +35 -0
- package/dist/client/container-card-paths.d.ts +20 -0
- package/dist/client/container-card-paths.js +88 -0
- package/dist/client/container-card-row.d.ts +30 -0
- package/dist/client/container-card-row.js +54 -0
- package/dist/client/container-card-secrets.d.ts +21 -0
- package/dist/client/container-card-secrets.js +71 -0
- package/dist/client/container-card-shared.d.ts +55 -0
- package/dist/client/container-card-shared.js +152 -0
- package/dist/client/container-card-styles.d.ts +21 -0
- package/dist/client/container-card-styles.js +143 -0
- package/dist/client/container-card-volumes.d.ts +12 -0
- package/dist/client/container-card-volumes.js +37 -0
- package/dist/client/container-card-workspace.d.ts +31 -0
- package/dist/client/container-card-workspace.js +24 -0
- package/dist/client/directory-picker.d.ts +12 -0
- package/dist/client/directory-picker.js +30 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +4419 -0
- package/dist/client/locales.d.ts +9 -0
- package/dist/client/locales.js +370 -0
- package/dist/client/read-only-approval.d.ts +8 -0
- package/dist/client/read-only-approval.js +63 -0
- package/dist/client/slot-contract.d.ts +12 -0
- package/dist/client/slot-contract.js +4 -0
- package/dist/client/terminal-styles.d.ts +5 -0
- package/dist/client/terminal-styles.js +31 -0
- package/dist/client/tool-views.d.ts +8 -0
- package/dist/client/tool-views.js +241 -0
- package/dist/containers.test.d.ts +1 -0
- package/dist/containers.test.js +443 -0
- package/dist/daemons.test.d.ts +1 -0
- package/dist/daemons.test.js +153 -0
- package/dist/fs-provider.d.ts +23 -0
- package/dist/fs-provider.js +184 -0
- package/dist/fs-provider.test.d.ts +1 -0
- package/dist/fs-provider.test.js +260 -0
- package/dist/generated/version.d.ts +2 -0
- package/dist/generated/version.js +6 -0
- package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
- package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
- package/dist/grpc/runtime-client.d.ts +5 -0
- package/dist/grpc/runtime-client.js +43 -0
- package/dist/guest-rpc.d.ts +62 -0
- package/dist/guest-rpc.js +391 -0
- package/dist/images.test.d.ts +1 -0
- package/dist/images.test.js +79 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +140 -0
- package/dist/misc.test.d.ts +1 -0
- package/dist/misc.test.js +141 -0
- package/dist/mount-enums.d.ts +22 -0
- package/dist/mount-enums.js +81 -0
- package/dist/mount-input.d.ts +14 -0
- package/dist/mount-input.js +64 -0
- package/dist/mounts.test.d.ts +1 -0
- package/dist/mounts.test.js +408 -0
- package/dist/output-reader.d.ts +34 -0
- package/dist/output-reader.js +87 -0
- package/dist/paths.test.d.ts +1 -0
- package/dist/paths.test.js +74 -0
- package/dist/preferences.d.ts +6 -0
- package/dist/preferences.js +27 -0
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.js +73 -0
- package/dist/project-path.test.d.ts +1 -0
- package/dist/project-path.test.js +52 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.js +149 -0
- package/dist/public.d.ts +4 -0
- package/dist/public.js +63 -0
- package/dist/read-only-shell.d.ts +14 -0
- package/dist/read-only-shell.js +150 -0
- package/dist/read-only-shell.test.d.ts +1 -0
- package/dist/read-only-shell.test.js +127 -0
- package/dist/secrets.test.d.ts +1 -0
- package/dist/secrets.test.js +181 -0
- package/dist/settings-commands.test.d.ts +1 -0
- package/dist/settings-commands.test.js +549 -0
- package/dist/settings-create.test.d.ts +1 -0
- package/dist/settings-create.test.js +394 -0
- package/dist/settings-mounts.test.d.ts +1 -0
- package/dist/settings-mounts.test.js +467 -0
- package/dist/settings-paths.test.d.ts +1 -0
- package/dist/settings-paths.test.js +65 -0
- package/dist/settings-schema.d.ts +14 -0
- package/dist/settings-schema.js +14 -0
- package/dist/settings-workspaces.test.d.ts +1 -0
- package/dist/settings-workspaces.test.js +265 -0
- package/dist/spill-store.d.ts +17 -0
- package/dist/spill-store.js +54 -0
- package/dist/spill-store.test.d.ts +1 -0
- package/dist/spill-store.test.js +59 -0
- package/dist/subprocess.d.ts +9 -0
- package/dist/subprocess.js +369 -0
- package/dist/subprocess.test.d.ts +1 -0
- package/dist/subprocess.test.js +234 -0
- package/dist/test-support.d.ts +328 -0
- package/dist/test-support.js +437 -0
- package/dist/tool-defs.d.ts +26 -0
- package/dist/tool-defs.js +188 -0
- package/dist/tool-handlers.d.ts +2 -0
- package/dist/tool-handlers.js +546 -0
- package/dist/tool-params.d.ts +803 -0
- package/dist/tool-params.js +494 -0
- package/dist/tool-schemas.d.ts +2 -0
- package/dist/tool-schemas.js +5 -0
- package/dist/tool-views.d.ts +2 -0
- package/dist/tool-views.js +56 -0
- package/dist/views.test.d.ts +1 -0
- package/dist/views.test.js +23 -0
- package/dist/volumes.test.d.ts +1 -0
- package/dist/volumes.test.js +40 -0
- package/dist/workspace-binding.d.ts +40 -0
- package/dist/workspace-binding.js +275 -0
- package/dist/workspace-binding.test.d.ts +1 -0
- package/dist/workspace-binding.test.js +337 -0
- package/docs/architecture.md +173 -0
- package/docs/architecture.zh.md +129 -0
- package/docs/configuration.md +160 -0
- package/docs/configuration.zh.md +147 -0
- package/docs/development.md +142 -0
- package/docs/development.zh.md +135 -0
- package/docs/install-dsh-and-dsh-podman.md +165 -0
- package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
- package/docs/usage.md +393 -0
- package/docs/usage.zh.md +315 -0
- package/package.json +119 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import { discardUnneededSpill, outputReader, spillTargetFor, splitEnv } from "./output-reader.js";
|
|
5
|
+
import { globCwd, remoteArgv, unaryGuest } from "./guest-rpc.js";
|
|
6
|
+
import { metadata } from "./workspace-binding.js";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { PassThrough } from "node:stream";
|
|
9
|
+
export function createSubprocessProvider(resolver) {
|
|
10
|
+
// Live termination handles, so service disposal can stop every process this
|
|
11
|
+
// provider started (the harness's disposal contract).
|
|
12
|
+
const live = new Set();
|
|
13
|
+
return {
|
|
14
|
+
dispose: () => {
|
|
15
|
+
for (const terminate of [...live])
|
|
16
|
+
terminate();
|
|
17
|
+
},
|
|
18
|
+
// The harness's resolveExecutable carries no cwd, and this provider's
|
|
19
|
+
// 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.
|
|
23
|
+
resolveExecutable: async (command) => {
|
|
24
|
+
throw new Error(`cannot resolve executable ${JSON.stringify(command)}: no workspace context is available`);
|
|
25
|
+
},
|
|
26
|
+
spawn: (spec) => {
|
|
27
|
+
// A pre-aborted spawn is rejected synchronously with a stable Error,
|
|
28
|
+
// mirroring the local backend, rather than starting a process that would
|
|
29
|
+
// be terminated immediately.
|
|
30
|
+
if (spec.signal?.aborted) {
|
|
31
|
+
let reason = "aborted";
|
|
32
|
+
try {
|
|
33
|
+
reason = String(spec.signal.reason ?? reason);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Arbitrary caller-owned reasons cannot escape the stable boundary.
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`aborted before spawn: ${reason}`);
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(spec.argv) ||
|
|
41
|
+
spec.argv.length === 0 ||
|
|
42
|
+
typeof spec.argv[0] !== "string" ||
|
|
43
|
+
spec.argv[0] === "")
|
|
44
|
+
throw new Error("argv must contain a program");
|
|
45
|
+
const stdoutSpill = spillTargetFor(spec.stdio?.stdout, "stdout");
|
|
46
|
+
const stderrSpill = spillTargetFor(spec.stdio?.stderr, "stderr");
|
|
47
|
+
const stdoutReader = outputReader(spec.stdio?.stdout, stdoutSpill.spec);
|
|
48
|
+
const stderrReader = outputReader(spec.stdio?.stderr, stderrSpill.spec);
|
|
49
|
+
const state = {
|
|
50
|
+
pid: -1,
|
|
51
|
+
stdin: undefined,
|
|
52
|
+
stdout: spec.stdio?.stdout === "pipe" ? new PassThrough() : undefined,
|
|
53
|
+
stderr: spec.stdio?.stderr === "pipe" ? new PassThrough() : undefined,
|
|
54
|
+
};
|
|
55
|
+
let processBinding;
|
|
56
|
+
let terminated = false;
|
|
57
|
+
let exited = false;
|
|
58
|
+
let killTimer;
|
|
59
|
+
const signalProcess = (signal) => {
|
|
60
|
+
if (processBinding !== undefined && state.pid > 0) {
|
|
61
|
+
void unaryGuest({ binding: processBinding }, "signal", {
|
|
62
|
+
processId: String(state.pid),
|
|
63
|
+
signal,
|
|
64
|
+
}).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// The harness's termination procedure: SIGTERM, then SIGKILL once the
|
|
68
|
+
// spec's grace period elapses without an exit.
|
|
69
|
+
const terminate = () => {
|
|
70
|
+
terminated = true;
|
|
71
|
+
signalProcess("SIGTERM");
|
|
72
|
+
if (killTimer === undefined) {
|
|
73
|
+
const grace = typeof spec.graceMs === "number" && spec.graceMs > 0
|
|
74
|
+
? spec.graceMs
|
|
75
|
+
: 5000;
|
|
76
|
+
killTimer = setTimeout(() => {
|
|
77
|
+
if (!exited)
|
|
78
|
+
signalProcess("SIGKILL");
|
|
79
|
+
}, grace);
|
|
80
|
+
killTimer.unref?.();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
// A ripgrep discovery listing runs from its search root, so a pattern
|
|
84
|
+
// containing "/" anchors to the path the harness passed; the workspace
|
|
85
|
+
// binding still resolves from the caller's cwd.
|
|
86
|
+
const runCwd = globCwd(spec.argv, spec.cwd) ?? spec.cwd;
|
|
87
|
+
const done = resolver.resolve(spec.cwd).then((binding) => new Promise((resolveDone, reject) => {
|
|
88
|
+
processBinding = binding;
|
|
89
|
+
const stream = binding.guest.exec(metadata(binding.token));
|
|
90
|
+
stream.on("data", (output) => {
|
|
91
|
+
if (output.stdoutChunk) {
|
|
92
|
+
const data = Buffer.from(output.stdoutChunk);
|
|
93
|
+
stdoutReader?.append(data);
|
|
94
|
+
state.stdout?.write(data);
|
|
95
|
+
if (spec.stdio?.stdout === "inherit")
|
|
96
|
+
process.stdout.write(data);
|
|
97
|
+
}
|
|
98
|
+
if (output.processId) {
|
|
99
|
+
state.pid = Number(output.processId);
|
|
100
|
+
if (terminated)
|
|
101
|
+
terminate();
|
|
102
|
+
}
|
|
103
|
+
if (output.stderrChunk) {
|
|
104
|
+
const data = Buffer.from(output.stderrChunk);
|
|
105
|
+
stderrReader?.append(data);
|
|
106
|
+
state.stderr?.write(data);
|
|
107
|
+
if (spec.stdio?.stderr === "inherit")
|
|
108
|
+
process.stderr.write(data);
|
|
109
|
+
}
|
|
110
|
+
if (output.exit) {
|
|
111
|
+
exited = true;
|
|
112
|
+
if (killTimer !== undefined)
|
|
113
|
+
clearTimeout(killTimer);
|
|
114
|
+
stdoutReader?.setSpillValid(Boolean(output.exit.stdoutSpillValid));
|
|
115
|
+
stderrReader?.setSpillValid(Boolean(output.exit.stderrSpillValid));
|
|
116
|
+
state.stdout?.end();
|
|
117
|
+
state.stderr?.end();
|
|
118
|
+
discardUnneededSpill(binding, stdoutReader);
|
|
119
|
+
discardUnneededSpill(binding, stderrReader);
|
|
120
|
+
resolveDone({
|
|
121
|
+
exitCode: output.exit.exitCode,
|
|
122
|
+
signal: output.exit.signaled ? output.exit.signal : null,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
stream.on("error", reject);
|
|
127
|
+
const env = splitEnv(spec.env);
|
|
128
|
+
stream.write({
|
|
129
|
+
start: {
|
|
130
|
+
argv: remoteArgv(spec.argv),
|
|
131
|
+
cwd: runCwd,
|
|
132
|
+
env: env.env,
|
|
133
|
+
unsetEnv: env.unsetEnv,
|
|
134
|
+
// A caller that will not send stdin leaves the child on
|
|
135
|
+
// /dev/null: a pipe would be a non-TTY stdin, and tools like
|
|
136
|
+
// ripgrep then read stdin instead of the working directory.
|
|
137
|
+
stdinPipe: spec.stdio?.stdin === "pipe",
|
|
138
|
+
...(stdoutSpill.target !== undefined
|
|
139
|
+
? { spillStdout: stdoutSpill.target }
|
|
140
|
+
: {}),
|
|
141
|
+
...(stderrSpill.target !== undefined
|
|
142
|
+
? { spillStderr: stderrSpill.target }
|
|
143
|
+
: {}),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (spec.stdio?.stdin !== "pipe")
|
|
147
|
+
stream.end();
|
|
148
|
+
else {
|
|
149
|
+
state.stdin = new PassThrough();
|
|
150
|
+
state.stdin.on("data", (data) => stream.write({ stdinChunk: data }));
|
|
151
|
+
state.stdin.on("end", () => stream.end());
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
if (spec.signal !== undefined) {
|
|
155
|
+
if (spec.signal.aborted)
|
|
156
|
+
terminate();
|
|
157
|
+
else
|
|
158
|
+
spec.signal.addEventListener("abort", terminate, { once: true });
|
|
159
|
+
}
|
|
160
|
+
live.add(terminate);
|
|
161
|
+
void done.finally(() => live.delete(terminate)).catch(() => { });
|
|
162
|
+
return {
|
|
163
|
+
...state,
|
|
164
|
+
collected: {
|
|
165
|
+
...(stdoutReader === undefined ? {} : { stdout: stdoutReader }),
|
|
166
|
+
...(stderrReader === undefined ? {} : { stderr: stderrReader }),
|
|
167
|
+
},
|
|
168
|
+
done,
|
|
169
|
+
terminate,
|
|
170
|
+
// Mirror the local backend's `waitWithAbort`: a pre-aborted or
|
|
171
|
+
// aborting signal returns false instead of waiting for the managed
|
|
172
|
+
// range, while a provider failure still propagates.
|
|
173
|
+
waitForExit: (signal) => {
|
|
174
|
+
if (signal?.aborted) {
|
|
175
|
+
void done.catch(() => { });
|
|
176
|
+
return Promise.resolve(false);
|
|
177
|
+
}
|
|
178
|
+
if (signal === undefined)
|
|
179
|
+
return done.then(() => true);
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const onAbort = () => resolve(false);
|
|
182
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
done.then(() => {
|
|
184
|
+
signal.removeEventListener("abort", onAbort);
|
|
185
|
+
resolve(true);
|
|
186
|
+
}, (error) => {
|
|
187
|
+
signal.removeEventListener("abort", onAbort);
|
|
188
|
+
reject(error);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
spawnTerminal: async (spec) => {
|
|
195
|
+
if (!Array.isArray(spec.argv) ||
|
|
196
|
+
spec.argv.length === 0 ||
|
|
197
|
+
typeof spec.argv[0] !== "string" ||
|
|
198
|
+
spec.argv[0] === "")
|
|
199
|
+
throw new Error("argv must contain a program");
|
|
200
|
+
spec.signal?.throwIfAborted();
|
|
201
|
+
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;
|
|
220
|
+
});
|
|
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;
|
|
265
|
+
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
|
+
};
|
|
303
|
+
const handle = {
|
|
304
|
+
pid,
|
|
305
|
+
output,
|
|
306
|
+
done,
|
|
307
|
+
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;
|
|
324
|
+
},
|
|
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;
|
|
334
|
+
},
|
|
335
|
+
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();
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
const onAbort = () => {
|
|
357
|
+
void handle.terminate();
|
|
358
|
+
};
|
|
359
|
+
if (spec.signal !== undefined) {
|
|
360
|
+
if (spec.signal.aborted)
|
|
361
|
+
onAbort();
|
|
362
|
+
else
|
|
363
|
+
spec.signal.addEventListener("abort", onAbort, { once: true });
|
|
364
|
+
}
|
|
365
|
+
return handle;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
export { outputReader } from "./output-reader.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,234 @@
|
|
|
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 { FakeTerminalCall, fakeTerminalResolver, spawnGuest, spawnSpec } from "./test-support.js";
|
|
7
|
+
import { createSubprocessProvider, globCwd, outputReader, remoteArgv } from "./index.js";
|
|
8
|
+
test("globCwd runs a discovery listing from its absolute search root", () => {
|
|
9
|
+
assert.equal(globCwd(["rg", "--files", "--glob=sub/b.txt", "--", "/tmp/globtest"], "/projects/team"), "/tmp/globtest");
|
|
10
|
+
assert.equal(globCwd(["rg", "--files", "--glob=a", "--", "sub"], "/projects/team"), undefined, "a relative root keeps the caller's cwd");
|
|
11
|
+
assert.equal(globCwd(["rg", "--files", "--glob=a"], "/projects/team"), undefined, "no search root keeps the caller's cwd");
|
|
12
|
+
assert.equal(globCwd(["rg", "-n", "x", "--", "/tmp/x"], "/projects/team"), undefined, "only a discovery listing is re-anchored");
|
|
13
|
+
assert.equal(globCwd(["bash", "-c", "rg"], "/projects/team"), undefined);
|
|
14
|
+
assert.equal(globCwd(["rg", "--files", "--", "/a", "/b"], "/projects/team"), undefined, "several search roots keep the caller's cwd");
|
|
15
|
+
});
|
|
16
|
+
test("subprocess provider runs a glob listing from its search root", async () => {
|
|
17
|
+
const fake = spawnGuest();
|
|
18
|
+
const provider = createSubprocessProvider(fake.resolver);
|
|
19
|
+
const handle = provider.spawn(spawnSpec({
|
|
20
|
+
argv: ["rg", "--files", "--glob=sub/b.txt", "--", "/tmp/globtest"],
|
|
21
|
+
}));
|
|
22
|
+
await handle.done;
|
|
23
|
+
const start = fake.starts[0].start;
|
|
24
|
+
assert.equal(start.cwd, "/tmp/globtest");
|
|
25
|
+
assert.deepEqual(start.argv, [
|
|
26
|
+
"/usr/bin/rg",
|
|
27
|
+
"--files",
|
|
28
|
+
"--glob=sub/b.txt",
|
|
29
|
+
"--",
|
|
30
|
+
"/tmp/globtest",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
test("remoteArgv remaps ripgrep onto the guest path", () => {
|
|
34
|
+
assert.deepEqual(remoteArgv(["rg", "-n", "foo"]), [
|
|
35
|
+
"/usr/bin/rg",
|
|
36
|
+
"-n",
|
|
37
|
+
"foo",
|
|
38
|
+
]);
|
|
39
|
+
assert.deepEqual(remoteArgv(["/usr/bin/rg", "-l", "x"]), [
|
|
40
|
+
"/usr/bin/rg",
|
|
41
|
+
"-l",
|
|
42
|
+
"x",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
test("remoteArgv unwraps landlock-run wrappers", () => {
|
|
46
|
+
assert.deepEqual(remoteArgv(["landlock-run", "--", "/usr/bin/rg", "-l"]), [
|
|
47
|
+
"/usr/bin/rg",
|
|
48
|
+
"-l",
|
|
49
|
+
]);
|
|
50
|
+
assert.deepEqual(remoteArgv(["landlock-run", "--", "rg", "-l", "src"]), ["/usr/bin/rg", "-l", "src"]);
|
|
51
|
+
});
|
|
52
|
+
test("remoteArgv leaves ordinary commands untouched", () => {
|
|
53
|
+
assert.deepEqual(remoteArgv(["bash", "-c", "echo hi"]), [
|
|
54
|
+
"bash",
|
|
55
|
+
"-c",
|
|
56
|
+
"echo hi",
|
|
57
|
+
]);
|
|
58
|
+
assert.deepEqual(remoteArgv(["landlock-run", "cmd", "arg"]), [
|
|
59
|
+
"landlock-run",
|
|
60
|
+
"cmd",
|
|
61
|
+
"arg",
|
|
62
|
+
]);
|
|
63
|
+
assert.deepEqual(remoteArgv([]), []);
|
|
64
|
+
});
|
|
65
|
+
test("outputReader is undefined without a bounded mode", () => {
|
|
66
|
+
assert.equal(outputReader(undefined), undefined);
|
|
67
|
+
assert.equal(outputReader(null), undefined);
|
|
68
|
+
assert.equal(outputReader("pipe"), undefined);
|
|
69
|
+
assert.equal(outputReader({ maxBytes: -1 }), undefined);
|
|
70
|
+
assert.equal(outputReader({ maxBytes: Number.NaN }), undefined);
|
|
71
|
+
});
|
|
72
|
+
test("outputReader reads appended output", () => {
|
|
73
|
+
const reader = outputReader({ maxBytes: 16 });
|
|
74
|
+
reader.append(Buffer.from("hello "));
|
|
75
|
+
reader.append(Buffer.from("world"));
|
|
76
|
+
const first = reader.readFrom(0);
|
|
77
|
+
assert.equal(first.text, "hello world");
|
|
78
|
+
assert.equal(first.nextOffset, 11);
|
|
79
|
+
assert.equal(first.lossy, false);
|
|
80
|
+
});
|
|
81
|
+
test("outputReader advertises a valid spill only for a lossy read", () => {
|
|
82
|
+
const spill = { path: "/tmp/dsh-podman/x.stdout", maxBytes: 64 };
|
|
83
|
+
const reader = outputReader({ maxBytes: 4 }, spill);
|
|
84
|
+
reader.append(Buffer.from("abcdefgh"));
|
|
85
|
+
assert.equal(reader.spillNeeded, true);
|
|
86
|
+
const lossy = reader.readFrom(0);
|
|
87
|
+
assert.equal(lossy.lossy, true);
|
|
88
|
+
assert.equal(lossy.spillPath, spill.path);
|
|
89
|
+
reader.setSpillValid(false);
|
|
90
|
+
assert.equal(reader.readFrom(0).spillPath, undefined);
|
|
91
|
+
const small = outputReader({ maxBytes: 64 }, spill);
|
|
92
|
+
small.append(Buffer.from("hi"));
|
|
93
|
+
assert.equal(small.spillNeeded, false);
|
|
94
|
+
assert.equal(small.readFrom(0).spillPath, undefined);
|
|
95
|
+
});
|
|
96
|
+
test("outputReader drops bytes beyond the retention window", () => {
|
|
97
|
+
const reader = outputReader({ maxBytes: 5 });
|
|
98
|
+
reader.append(Buffer.from("hello"));
|
|
99
|
+
reader.append(Buffer.from("world"));
|
|
100
|
+
const read = reader.readFrom(0);
|
|
101
|
+
assert.equal(read.text, "world");
|
|
102
|
+
assert.equal(read.nextOffset, 10);
|
|
103
|
+
assert.equal(read.lossy, true);
|
|
104
|
+
});
|
|
105
|
+
test("outputReader honors in-window offsets", () => {
|
|
106
|
+
const reader = outputReader({ maxBytes: 5 });
|
|
107
|
+
reader.append(Buffer.from("abcdefghij"));
|
|
108
|
+
const atStart = reader.readFrom(5);
|
|
109
|
+
assert.equal(atStart.text, "fghij");
|
|
110
|
+
assert.equal(atStart.lossy, false);
|
|
111
|
+
const pastEnd = reader.readFrom(10);
|
|
112
|
+
assert.equal(pastEnd.text, "");
|
|
113
|
+
assert.equal(pastEnd.lossy, false);
|
|
114
|
+
});
|
|
115
|
+
test("resolveExecutable rejects every request without a workspace context", async () => {
|
|
116
|
+
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"));
|
|
121
|
+
});
|
|
122
|
+
test("spawnTerminal drives the guest terminal stream", async () => {
|
|
123
|
+
const fake = new FakeTerminalCall();
|
|
124
|
+
const provider = createSubprocessProvider(fakeTerminalResolver(fake));
|
|
125
|
+
const handle = await provider.spawnTerminal({
|
|
126
|
+
argv: ["bash"],
|
|
127
|
+
cwd: "/projects/team",
|
|
128
|
+
rows: 24,
|
|
129
|
+
cols: 80,
|
|
130
|
+
graceMs: 1000,
|
|
131
|
+
});
|
|
132
|
+
assert.equal(handle.pid, 42);
|
|
133
|
+
const chunks = [];
|
|
134
|
+
handle.output.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
135
|
+
const outputEnded = new Promise((resolve) => handle.output.on("end", resolve));
|
|
136
|
+
fake.emitStdout(Buffer.from("hello"));
|
|
137
|
+
await handle.write("ls\n");
|
|
138
|
+
assert.deepEqual(fake.stdinChunks.map((chunk) => chunk.toString("utf8")), ["ls\n"]);
|
|
139
|
+
assert.deepEqual(await handle.inspectForeground(), {
|
|
140
|
+
processGroupId: 7,
|
|
141
|
+
inputWaiting: true,
|
|
142
|
+
});
|
|
143
|
+
assert.equal(await handle.signalForeground("SIGINT"), 7);
|
|
144
|
+
fake.emitExit(0, false);
|
|
145
|
+
assert.deepEqual(await handle.done, { exitCode: 0, signal: null });
|
|
146
|
+
await outputEnded;
|
|
147
|
+
assert.equal(Buffer.concat(chunks).toString("utf8"), "hello");
|
|
148
|
+
await handle.terminate();
|
|
149
|
+
});
|
|
150
|
+
test("spawnTerminal rejects an empty argv", async () => {
|
|
151
|
+
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/);
|
|
153
|
+
});
|
|
154
|
+
test("subprocess provider terminates live processes on dispose", async () => {
|
|
155
|
+
const { signals, resolver } = spawnGuest({ emitExit: false });
|
|
156
|
+
const provider = createSubprocessProvider(resolver);
|
|
157
|
+
provider.spawn(spawnSpec());
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
159
|
+
provider.dispose();
|
|
160
|
+
assert.ok(signals.some((signal) => signal.signal === "SIGTERM"));
|
|
161
|
+
});
|
|
162
|
+
test("subprocess provider terminates on the spec abort signal", async () => {
|
|
163
|
+
const { signals, resolver } = spawnGuest({ emitExit: false });
|
|
164
|
+
const provider = createSubprocessProvider(resolver);
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
provider.spawn(spawnSpec({ signal: controller.signal }));
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
168
|
+
controller.abort();
|
|
169
|
+
assert.ok(signals.some((signal) => signal.signal === "SIGTERM"));
|
|
170
|
+
});
|
|
171
|
+
test("subprocess provider rejects a pre-aborted spawn synchronously", () => {
|
|
172
|
+
const { resolver } = spawnGuest();
|
|
173
|
+
const provider = createSubprocessProvider(resolver);
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
controller.abort(new Error("stop"));
|
|
176
|
+
assert.throws(() => provider.spawn(spawnSpec({ signal: controller.signal })), /aborted before spawn: Error: stop/);
|
|
177
|
+
});
|
|
178
|
+
test("subprocess provider waitForExit honors an abort signal", async () => {
|
|
179
|
+
const live = spawnGuest({ emitExit: false });
|
|
180
|
+
const liveProvider = createSubprocessProvider(live.resolver);
|
|
181
|
+
const handle = liveProvider.spawn(spawnSpec());
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const pending = handle.waitForExit(controller.signal);
|
|
185
|
+
controller.abort();
|
|
186
|
+
assert.equal(await pending, false);
|
|
187
|
+
liveProvider.dispose();
|
|
188
|
+
const exited = spawnGuest();
|
|
189
|
+
const exitedProvider = createSubprocessProvider(exited.resolver);
|
|
190
|
+
const exitedHandle = exitedProvider.spawn(spawnSpec());
|
|
191
|
+
assert.equal(await exitedHandle.waitForExit(), true);
|
|
192
|
+
});
|
|
193
|
+
test("subprocess provider forwards env tombstones and spills", async () => {
|
|
194
|
+
const fake = spawnGuest({ exit: { stdoutSpillValid: true } });
|
|
195
|
+
const provider = createSubprocessProvider(fake.resolver);
|
|
196
|
+
const handle = provider.spawn(spawnSpec({
|
|
197
|
+
env: { KEEP: "1", DROP: undefined },
|
|
198
|
+
stdio: {
|
|
199
|
+
stdin: "ignore",
|
|
200
|
+
stdout: { maxBytes: 4, spill: { maxBytes: 64 } },
|
|
201
|
+
stderr: { maxBytes: 4 },
|
|
202
|
+
},
|
|
203
|
+
}));
|
|
204
|
+
await handle.done;
|
|
205
|
+
const start = fake.starts[0].start;
|
|
206
|
+
assert.deepEqual(start.env, { KEEP: "1" });
|
|
207
|
+
assert.deepEqual(start.unsetEnv, ["DROP"]);
|
|
208
|
+
assert.equal(start.spillStdout.maxBytes, 64);
|
|
209
|
+
assert.match(start.spillStdout.path, /^\/tmp\/dsh-podman\/.+\.stdout$/);
|
|
210
|
+
assert.equal(start.spillStderr, undefined);
|
|
211
|
+
// No output was emitted, so the in-memory tail covered everything and the
|
|
212
|
+
// unneeded spill is discarded.
|
|
213
|
+
assert.equal(fake.deletes.length, 1);
|
|
214
|
+
assert.equal(fake.deletes[0].path, start.spillStdout.path);
|
|
215
|
+
});
|
|
216
|
+
test("subprocess provider keeps a spill that holds dropped output", async () => {
|
|
217
|
+
const fake = spawnGuest({
|
|
218
|
+
exit: { stdoutSpillValid: true },
|
|
219
|
+
stdout: "0123456789",
|
|
220
|
+
});
|
|
221
|
+
const provider = createSubprocessProvider(fake.resolver);
|
|
222
|
+
const handle = provider.spawn(spawnSpec({
|
|
223
|
+
stdio: {
|
|
224
|
+
stdin: "ignore",
|
|
225
|
+
stdout: { maxBytes: 4, spill: { maxBytes: 64 } },
|
|
226
|
+
stderr: { maxBytes: 4 },
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
229
|
+
await handle.done;
|
|
230
|
+
assert.equal(fake.deletes.length, 0);
|
|
231
|
+
const read = handle.collected.stdout.readFrom(0);
|
|
232
|
+
assert.equal(read.lossy, true);
|
|
233
|
+
assert.equal(read.spillPath, fake.starts[0].start.spillStdout.path);
|
|
234
|
+
});
|