@hheei/ssh-exec-mcp 0.1.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.
@@ -0,0 +1,364 @@
1
+ import { afterEach, beforeEach, expect, test } from "bun:test";
2
+ import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SessionManager, sanitizeHostForSocket, type MountProbeResult } from "./session-manager";
6
+ import { clampTimeoutSeconds, executeSshExec } from "./ssh-exec";
7
+
8
+ let tmpRoot: string;
9
+ let fakeSshPath: string;
10
+ let fakeLogPath: string;
11
+
12
+ beforeEach(async () => {
13
+ tmpRoot = await mkdtemp(join(tmpdir(), "ssh-exec-test-"));
14
+ fakeSshPath = join(tmpRoot, "fake-ssh.ts");
15
+ fakeLogPath = join(tmpRoot, "ssh.log");
16
+ await Bun.write(fakeSshPath, fakeSshSource(fakeLogPath));
17
+ await chmod(fakeSshPath, 0o755);
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await rm(tmpRoot, { recursive: true, force: true });
22
+ });
23
+
24
+ test("executeSshExec establishes control-master and reuses host session", async () => {
25
+ const controlDir = join(tmpRoot, "control-master");
26
+ const manager = new SessionManager({ sshBin: fakeSshPath, controlDir });
27
+
28
+ const first = await executeSshExec(manager, { host: "prod", command: "say-hello", timeout: 5 });
29
+ const second = await executeSshExec(manager, { host: "prod", command: "say-hello", timeout: 5 });
30
+
31
+ expect(first).toMatchObject({
32
+ host: "prod",
33
+ exitCode: 0,
34
+ stdout: "hello\n",
35
+ stderr: "warn\n",
36
+ truncated: false,
37
+ });
38
+ expect(second.exitCode).toBe(0);
39
+ expect((await stat(controlDir)).mode & 0o777).toBe(0o700);
40
+ const events = await readFakeLog();
41
+ expect(events.filter((event) => event.kind === "check").length).toBeGreaterThanOrEqual(2);
42
+ expect(events.filter((event) => event.kind === "start").length).toBeGreaterThanOrEqual(1);
43
+ expect(events.filter((event) => event.kind === "run")).toHaveLength(2);
44
+ });
45
+
46
+ test("executeSshExec returns timeout result with captured output when requested", async () => {
47
+ const manager = new SessionManager({ sshBin: fakeSshPath, controlDir: join(tmpRoot, "control-master") });
48
+ const result = await executeSshExec(
49
+ manager,
50
+ { host: "prod", command: "slow-output", timeout: 2 },
51
+ { timeoutMode: "result" },
52
+ );
53
+
54
+ expect(result.exitCode).toBeNull();
55
+ expect(result.notice).toContain("timed out");
56
+ expect(result.stdout).toContain("before timeout");
57
+ expect(result.stderr).toContain("<control-socket>");
58
+ expect(result.stderr).not.toContain(tmpRoot);
59
+ });
60
+
61
+ test("executeSshExec rejects host values OpenSSH could parse as options", async () => {
62
+ const manager = new SessionManager({ sshBin: fakeSshPath, controlDir: join(tmpRoot, "control-master") });
63
+ await expect(executeSshExec(manager, { host: "-oProxyCommand=sh", command: "say-hello", timeout: 5 })).rejects.toThrow(
64
+ /must not start with '-'/i,
65
+ );
66
+ });
67
+
68
+ test("ensureMounted runs sshfs first time and reuses healthy mount", async () => {
69
+ const mountDir = join(tmpRoot, "mounts");
70
+ const mounted = new Set<string>();
71
+ const manager = new SessionManager({
72
+ sshBin: fakeSshPath,
73
+ sshfsBin: fakeSshPath,
74
+ controlDir: join(tmpRoot, "control-master"),
75
+ mountDir,
76
+ mountProbe: async (mountPath): Promise<MountProbeResult> => ({
77
+ mounted: mounted.has(mountPath) || (await Bun.file(join(mountPath, ".mounted")).exists()),
78
+ healthy: mounted.has(mountPath) || (await Bun.file(join(mountPath, ".mounted")).exists()),
79
+ }),
80
+ unmountMount: async (mountPath) => {
81
+ mounted.delete(mountPath);
82
+ await rm(join(mountPath, ".mounted"), { force: true });
83
+ return true;
84
+ },
85
+ });
86
+ const runner = async (args: string[], timeoutMs?: number) => await runFakeProcess(fakeSshPath, args, timeoutMs);
87
+
88
+ const first = await manager.ensureMounted("prod", runner);
89
+ mounted.add(first.localPath);
90
+ const second = await manager.ensureMounted("prod", runner);
91
+
92
+ expect(first.status).toBe("mounted");
93
+ expect(second.status).toBe("reused");
94
+ expect(first.localPath).toBe(join(mountDir, sanitizeHostForSocket("prod")));
95
+ const events = await readFakeLog();
96
+ expect(events.filter((event) => event.kind === "mount")).toHaveLength(1);
97
+ });
98
+
99
+ test("ensureMounted remounts stale mount and closeAll unmounts best-effort", async () => {
100
+ const mountDir = join(tmpRoot, "mounts");
101
+ const unmounted: string[] = [];
102
+ const mountState = new Map<string, MountProbeResult>();
103
+ const manager = new SessionManager({
104
+ sshBin: fakeSshPath,
105
+ sshfsBin: fakeSshPath,
106
+ controlDir: join(tmpRoot, "control-master"),
107
+ mountDir,
108
+ mountProbe: async (mountPath) => {
109
+ const fileMounted = await Bun.file(join(mountPath, ".mounted")).exists();
110
+ if (fileMounted) return { mounted: true, healthy: true };
111
+ return mountState.get(mountPath) ?? { mounted: false, healthy: false };
112
+ },
113
+ unmountMount: async (mountPath) => {
114
+ unmounted.push(mountPath);
115
+ mountState.set(mountPath, { mounted: false, healthy: false });
116
+ await rm(join(mountPath, ".mounted"), { force: true });
117
+ return true;
118
+ },
119
+ });
120
+ const runner = async (args: string[], timeoutMs?: number) => await runFakeProcess(fakeSshPath, args, timeoutMs);
121
+ const mountPath = manager.getMountPath("prod");
122
+ await Bun.write(join(mountPath, ".keep"), "");
123
+ await Bun.write(join(mountPath, ".stale"), "stale");
124
+ mountState.set(mountPath, { mounted: true, healthy: false });
125
+
126
+ const mountedResult = await manager.ensureMounted("prod", runner);
127
+ mountState.set(mountPath, { mounted: true, healthy: true });
128
+ await manager.closeAll(runner);
129
+
130
+ expect(mountedResult.status).toBe("remounted");
131
+ expect(unmounted).toContain(mountPath);
132
+ expect(unmounted.filter((path) => path === mountPath).length).toBeGreaterThanOrEqual(2);
133
+ });
134
+
135
+ test("ensureMounted errors clearly when sshfs binary is missing", async () => {
136
+ const manager = new SessionManager({
137
+ sshBin: fakeSshPath,
138
+ sshfsBin: join(tmpRoot, "missing-sshfs"),
139
+ controlDir: join(tmpRoot, "control-master"),
140
+ mountDir: join(tmpRoot, "mounts"),
141
+ });
142
+ const runner = async (args: string[], timeoutMs?: number) => await runFakeProcess(fakeSshPath, args, timeoutMs);
143
+
144
+ await expect(manager.ensureMounted("prod", runner)).rejects.toThrow(/sshfs binary not found/i);
145
+ });
146
+
147
+ test("ensureMounted rejects unsupported local platforms", async () => {
148
+ const manager = new SessionManager({
149
+ sshBin: fakeSshPath,
150
+ sshfsBin: fakeSshPath,
151
+ controlDir: join(tmpRoot, "control-master"),
152
+ mountDir: join(tmpRoot, "mounts"),
153
+ platform: "win32",
154
+ });
155
+ const runner = async (args: string[], timeoutMs?: number) => await runFakeProcess(fakeSshPath, args, timeoutMs);
156
+
157
+ await expect(manager.ensureMounted("prod", runner)).rejects.toThrow(/supported only on Linux and macOS/i);
158
+ });
159
+
160
+ test("clampTimeoutSeconds keeps timeouts in supported range", () => {
161
+ expect(clampTimeoutSeconds(undefined)).toBe(10);
162
+ expect(clampTimeoutSeconds(0.1)).toBe(1);
163
+ expect(clampTimeoutSeconds(99999)).toBe(3600);
164
+ });
165
+
166
+ test("SessionManager defaults give SSH master setup up to 30 seconds total", async () => {
167
+ const timeouts: number[] = [];
168
+ const manager = new SessionManager({
169
+ sshBin: fakeSshPath,
170
+ controlDir: join(tmpRoot, "control-master"),
171
+ });
172
+ const runner = async (args: string[], timeoutMs?: number) => {
173
+ timeouts.push(timeoutMs ?? -1);
174
+ if (args.includes("-O") && args.includes("check")) {
175
+ return {
176
+ exitCode: 255,
177
+ stdout: "",
178
+ stderr: "No ControlMaster",
179
+ };
180
+ }
181
+ return {
182
+ exitCode: 0,
183
+ stdout: "",
184
+ stderr: "",
185
+ };
186
+ };
187
+
188
+ await manager.ensureConnected("prod", runner);
189
+
190
+ expect(manager.connectTimeoutSeconds).toBe(30);
191
+ expect(timeouts).toHaveLength(2);
192
+ expect(timeouts[0]).toBe(10_000);
193
+ expect(timeouts[1]).toBeGreaterThan(20_000);
194
+ expect(timeouts[1]).toBeLessThanOrEqual(30_000);
195
+ });
196
+
197
+ test("SessionManager defaults mount root under ~/.cache/ssh-exec", () => {
198
+ const manager = new SessionManager({
199
+ sshBin: fakeSshPath,
200
+ });
201
+
202
+ expect(manager.getMountPath("prod")).toContain("/.cache/ssh-exec/");
203
+ });
204
+
205
+ test("ensureConnected blocks only after repeated master-start failures", async () => {
206
+ const failingSshPath = join(tmpRoot, "fake-ssh-fail.ts");
207
+ await Bun.write(failingSshPath, failingFakeSshSource(fakeLogPath));
208
+ await chmod(failingSshPath, 0o755);
209
+ const manager = new SessionManager({
210
+ sshBin: failingSshPath,
211
+ controlDir: join(tmpRoot, "control-master"),
212
+ failureBackoffMs: 1_000,
213
+ });
214
+ const runner = async (args: string[], timeoutMs?: number) => await runFakeProcess(failingSshPath, args, timeoutMs);
215
+
216
+ await expect(manager.ensureConnected("prod", runner)).rejects.toThrow(/simulated connect timeout/i);
217
+ await expect(manager.ensureConnected("prod", runner)).rejects.toThrow(/simulated connect timeout/i);
218
+ await expect(manager.ensureConnected("prod", runner)).rejects.toThrow(/temporarily blocked/i);
219
+ });
220
+
221
+ async function readFakeLog(): Promise<Array<{ kind: string; args: string[] }>> {
222
+ const raw = await readFile(fakeLogPath, "utf8").catch(() => "");
223
+ return raw
224
+ .split("\n")
225
+ .filter(Boolean)
226
+ .map((line) => JSON.parse(line) as { kind: string; args: string[] });
227
+ }
228
+
229
+ async function runFakeProcess(bin: string, args: string[], timeoutMs = 10_000) {
230
+ const child = Bun.spawn([bin, ...args], {
231
+ stdin: "ignore",
232
+ stdout: "pipe",
233
+ stderr: "pipe",
234
+ });
235
+ const timeout = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
236
+ try {
237
+ const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
238
+ child.stdout ? new Response(child.stdout).bytes() : new Uint8Array(),
239
+ child.stderr ? new Response(child.stderr).bytes() : new Uint8Array(),
240
+ child.exited,
241
+ ]);
242
+ const stdout = new TextDecoder().decode(stdoutBytes);
243
+ const stderr = new TextDecoder().decode(stderrBytes);
244
+ return {
245
+ exitCode,
246
+ stdout,
247
+ stderr,
248
+ output: `${stdout}${stderr}`,
249
+ truncated: false,
250
+ };
251
+ } finally {
252
+ clearTimeout(timeout);
253
+ }
254
+ }
255
+
256
+ function failingFakeSshSource(logPath: string): string {
257
+ return `#!/usr/bin/env bun
258
+ import { appendFile, mkdir } from "node:fs/promises";
259
+ import { dirname } from "node:path";
260
+
261
+ const args = process.argv.slice(2);
262
+ const logFile = ${JSON.stringify(logPath)};
263
+
264
+ async function log(kind) {
265
+ await mkdir(dirname(logFile), { recursive: true });
266
+ await appendFile(logFile, JSON.stringify({ kind, args }) + "\\n");
267
+ }
268
+
269
+ const socketIndex = args.indexOf("-S");
270
+ const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
271
+
272
+ if (args.includes("-O") && args.includes("check")) {
273
+ await log("check");
274
+ console.error("No ControlMaster");
275
+ process.exit(255);
276
+ }
277
+
278
+ if (args.includes("-M") && args.includes("-N") && args.includes("-f")) {
279
+ await log("start");
280
+ if (socketPath) {
281
+ await mkdir(dirname(socketPath), { recursive: true });
282
+ }
283
+ console.error("simulated connect timeout");
284
+ process.exit(255);
285
+ }
286
+
287
+ await log("run");
288
+ process.stdout.write("ok\\n");
289
+ process.exit(0);
290
+ `;
291
+ }
292
+
293
+ function fakeSshSource(logPath: string): string {
294
+ return `#!/usr/bin/env bun
295
+ import { appendFile, mkdir } from "node:fs/promises";
296
+ import { dirname } from "node:path";
297
+
298
+ const args = process.argv.slice(2);
299
+ const logPath = ${JSON.stringify(logPath)};
300
+
301
+ function argAfter(flag) {
302
+ const index = args.indexOf(flag);
303
+ return index === -1 ? undefined : args[index + 1];
304
+ }
305
+
306
+ async function log(kind) {
307
+ await appendFile(logPath, JSON.stringify({ kind, args }) + "\\n");
308
+ }
309
+
310
+ const socketPath = argAfter("-S");
311
+ const controlPathArg = args.find((arg) => typeof arg === "string" && arg.startsWith("ControlPath="));
312
+ const sshfsSocketPath = controlPathArg ? controlPathArg.slice("ControlPath=".length) : undefined;
313
+ const effectiveSocketPath = socketPath ?? sshfsSocketPath;
314
+ const mountTarget = args.at(-2);
315
+ const mountPath = args.at(-1);
316
+
317
+ if (args.includes("-O") && args.includes("check")) {
318
+ await log("check");
319
+ if (effectiveSocketPath && await Bun.file(effectiveSocketPath).exists()) process.exit(0);
320
+ console.error("No ControlMaster");
321
+ process.exit(255);
322
+ }
323
+
324
+ if (args.includes("-M") && args.includes("-N") && args.includes("-f")) {
325
+ await log("start");
326
+ if (!effectiveSocketPath) process.exit(2);
327
+ await mkdir(dirname(effectiveSocketPath), { recursive: true });
328
+ await Bun.write(effectiveSocketPath, "master");
329
+ process.exit(0);
330
+ }
331
+
332
+ if (typeof mountTarget === "string" && mountTarget.endsWith(":/") && typeof mountPath === "string") {
333
+ await log("mount");
334
+ await mkdir(mountPath, { recursive: true });
335
+ await Bun.write(mountPath + "/.mounted", "mounted");
336
+ process.exit(0);
337
+ }
338
+
339
+ await log("run");
340
+ const cmd = args[args.length - 1] ?? "";
341
+
342
+ if (cmd === "say-hello") {
343
+ process.stdout.write("hello\\n");
344
+ process.stderr.write("warn\\n");
345
+ process.exit(0);
346
+ }
347
+
348
+ if (cmd === "slow-output") {
349
+ process.stdout.write("before timeout\\n");
350
+ process.stderr.write("err " + (effectiveSocketPath ?? "") + "\\n");
351
+ await Bun.sleep(2000);
352
+ process.exit(0);
353
+ }
354
+
355
+ if (cmd === "fail-command") {
356
+ process.stdout.write("bad output\\n");
357
+ process.stderr.write("bad err " + (effectiveSocketPath ?? "") + "\\n");
358
+ process.exit(7);
359
+ }
360
+
361
+ process.stdout.write("ok\\n");
362
+ process.exit(0);
363
+ `;
364
+ }
@@ -0,0 +1,336 @@
1
+ import { SessionManager, type ProcessResult } from "./session-manager";
2
+ import { OutputTailSink, type OutputSource } from "./output-tail-sink";
3
+
4
+ export const MAX_OUTPUT_BYTES = 50 * 1024;
5
+
6
+ export interface SshExecArgs {
7
+ host: string;
8
+ command: string;
9
+ timeout?: number;
10
+ }
11
+
12
+ export interface SshExecResult {
13
+ host: string;
14
+ exitCode: number | null;
15
+ output?: string;
16
+ stdout: string;
17
+ stderr: string;
18
+ durationMs: number;
19
+ truncated: boolean;
20
+ totalBytes?: number;
21
+ outputBytes?: number;
22
+ totalLines?: number;
23
+ outputLines?: number;
24
+ notice?: string;
25
+ }
26
+
27
+ export interface ExecuteSshExecOptions {
28
+ timeoutMode?: "throw" | "result";
29
+ }
30
+
31
+ export function validateSshExecArgs(value: unknown): SshExecArgs {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
33
+ throw new Error("ssh_exec arguments must be an object");
34
+ }
35
+
36
+ const record = value as Record<string, unknown>;
37
+ if (typeof record.host !== "string" || record.host.trim() === "") {
38
+ throw new Error("ssh_exec.host must be a non-empty string");
39
+ }
40
+ const command = record.command ?? record.cmd;
41
+ if (typeof command !== "string" || command.trim() === "") {
42
+ throw new Error("ssh_exec.command must be a non-empty string");
43
+ }
44
+ if (
45
+ record.timeout !== undefined &&
46
+ (typeof record.timeout !== "number" || !Number.isFinite(record.timeout))
47
+ ) {
48
+ throw new Error("ssh_exec.timeout must be a finite number");
49
+ }
50
+
51
+ return {
52
+ host: validateHost(record.host),
53
+ command,
54
+ timeout: record.timeout as number | undefined,
55
+ };
56
+ }
57
+
58
+ export async function executeSshExec(
59
+ manager: SessionManager,
60
+ args: SshExecArgs,
61
+ options: ExecuteSshExecOptions = {},
62
+ ): Promise<SshExecResult> {
63
+ const startedAt = Date.now();
64
+ const host = validateHost(args.host);
65
+ const timeoutMs = clampTimeoutSeconds(args.timeout) * 1000;
66
+ const deadlineMs = startedAt + timeoutMs;
67
+ const session = manager.get(host);
68
+ const runner = (sshArgs: string[], timeoutOverrideMs?: number) =>
69
+ runSshProcess(
70
+ manager.sshBin,
71
+ sshArgs,
72
+ remainingTimeoutMs(deadlineMs, timeoutOverrideMs ?? timeoutMs),
73
+ {
74
+ timeoutMode: options.timeoutMode ?? "throw",
75
+ sensitiveValues: manager.sensitiveValues(host),
76
+ },
77
+ );
78
+
79
+ await manager.ensureConnected(host, runner);
80
+ const result = await runner(manager.buildRunArgs(session, args.command));
81
+
82
+ session.lastUsed = Date.now();
83
+ return {
84
+ host,
85
+ exitCode: result.exitCode,
86
+ output: result.output ?? `${result.stdout}${result.stderr}`,
87
+ stdout: result.stdout,
88
+ stderr: result.stderr,
89
+ durationMs: Date.now() - startedAt,
90
+ truncated: Boolean(result.truncated || result.stdoutTruncated || result.stderrTruncated),
91
+ totalBytes: result.totalBytes,
92
+ outputBytes: result.outputBytes,
93
+ totalLines: result.totalLines,
94
+ outputLines: result.outputLines,
95
+ notice: result.notice ? manager.sanitize(result.notice) : undefined,
96
+ };
97
+ }
98
+
99
+ export function validateHost(host: string): string {
100
+ const trimmed = host.trim();
101
+ if (!trimmed) {
102
+ throw new Error("ssh_exec.host must be a non-empty string");
103
+ }
104
+ if (trimmed.startsWith("-")) {
105
+ throw new Error("ssh_exec.host must not start with '-'");
106
+ }
107
+ if (/[\0\r\n\t ]/.test(trimmed)) {
108
+ throw new Error("ssh_exec.host must not contain whitespace or control characters");
109
+ }
110
+ return trimmed;
111
+ }
112
+
113
+ export function clampTimeoutSeconds(value: number | undefined): number {
114
+ const raw = value ?? 10;
115
+ return Math.min(3600, Math.max(1, raw));
116
+ }
117
+
118
+ function remainingTimeoutMs(deadlineMs: number, timeoutMs: number): number {
119
+ return Math.min(timeoutMs, Math.max(1, deadlineMs - Date.now()));
120
+ }
121
+
122
+ async function runSshProcess(
123
+ sshBin: string,
124
+ args: string[],
125
+ timeoutMs: number,
126
+ options: ExecuteSshExecOptions & { sensitiveValues?: string[] } = {},
127
+ ): Promise<ProcessResult> {
128
+ const child = Bun.spawn([sshBin, ...args], {
129
+ stdin: "ignore",
130
+ stdout: "pipe",
131
+ stderr: "pipe",
132
+ });
133
+ let timedOut = false;
134
+ let killTimer: Timer | undefined;
135
+ const timeout = setTimeout(() => {
136
+ timedOut = true;
137
+ child.kill("SIGTERM");
138
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 1000);
139
+ }, timeoutMs);
140
+
141
+ try {
142
+ const [output, exitCode] = await Promise.all([
143
+ readProcessOutputTail(child.stdout, child.stderr, options.sensitiveValues ?? []),
144
+ child.exited,
145
+ ]);
146
+ if (timedOut) {
147
+ const notice = `SSH command timed out after ${Math.round(timeoutMs / 1000)}s`;
148
+ if (options.timeoutMode === "result") {
149
+ return {
150
+ exitCode: null,
151
+ output: output.text,
152
+ stdout: output.stdout,
153
+ stderr: output.stderr,
154
+ truncated: output.truncated,
155
+ totalBytes: output.totalBytes,
156
+ outputBytes: output.outputBytes,
157
+ totalLines: output.totalLines,
158
+ outputLines: output.outputLines,
159
+ notice,
160
+ };
161
+ }
162
+ throw new Error(notice);
163
+ }
164
+ return {
165
+ exitCode,
166
+ output: output.text,
167
+ stdout: output.stdout,
168
+ stderr: output.stderr,
169
+ truncated: output.truncated,
170
+ totalBytes: output.totalBytes,
171
+ outputBytes: output.outputBytes,
172
+ totalLines: output.totalLines,
173
+ outputLines: output.outputLines,
174
+ };
175
+ } finally {
176
+ clearTimeout(timeout);
177
+ if (killTimer) clearTimeout(killTimer);
178
+ }
179
+ }
180
+
181
+ export async function readProcessOutputTail(
182
+ stdout: ReadableStream<Uint8Array> | null,
183
+ stderr: ReadableStream<Uint8Array> | null,
184
+ sensitiveValues: string[],
185
+ maxBytes = MAX_OUTPUT_BYTES,
186
+ ) {
187
+ const sink = new OutputTailSink(maxBytes);
188
+ await pipeStreamsToSink(stdout, stderr, sink, sensitiveValues);
189
+ return sink.dump();
190
+ }
191
+
192
+ interface StreamState {
193
+ source: OutputSource;
194
+ reader: ReadableStreamDefaultReader<Uint8Array>;
195
+ decoder: TextDecoder;
196
+ redactor: StreamingRedactor;
197
+ pending: Promise<ReadableStreamReadResult<Uint8Array>>;
198
+ }
199
+
200
+ async function pipeStreamsToSink(
201
+ stdout: ReadableStream<Uint8Array> | null,
202
+ stderr: ReadableStream<Uint8Array> | null,
203
+ sink: OutputTailSink,
204
+ sensitiveValues: string[],
205
+ ): Promise<void> {
206
+ const states: StreamState[] = [];
207
+ if (stdout) states.push(createStreamState("stdout", stdout, sensitiveValues));
208
+ if (stderr) states.push(createStreamState("stderr", stderr, sensitiveValues));
209
+
210
+ try {
211
+ while (states.length > 0) {
212
+ const { index, state, result } = await Promise.race(
213
+ states.map((state, index) => state.pending.then((result) => ({ index, state, result }))),
214
+ );
215
+ const { done, value } = result;
216
+ if (done) {
217
+ const flushed = state.redactor.write(state.decoder.decode()) + state.redactor.flush();
218
+ if (flushed) sink.write(state.source, flushed);
219
+ state.reader.releaseLock();
220
+ states.splice(index, 1);
221
+ continue;
222
+ }
223
+ if (value) {
224
+ const text = state.decoder.decode(value, { stream: true });
225
+ const redacted = state.redactor.write(text);
226
+ if (redacted) sink.write(state.source, redacted);
227
+ }
228
+ state.pending = state.reader.read();
229
+ }
230
+ } finally {
231
+ for (const state of states) {
232
+ state.reader.releaseLock();
233
+ }
234
+ }
235
+ }
236
+
237
+ function createStreamState(
238
+ source: OutputSource,
239
+ stream: ReadableStream<Uint8Array>,
240
+ sensitiveValues: string[],
241
+ ): StreamState {
242
+ const reader = stream.getReader();
243
+ return {
244
+ source,
245
+ reader,
246
+ decoder: new TextDecoder(),
247
+ redactor: new StreamingRedactor(sensitiveValues),
248
+ pending: reader.read(),
249
+ };
250
+ }
251
+
252
+ class StreamingRedactor {
253
+ private readonly values: string[];
254
+ private readonly retainChars: number;
255
+ private pending = "";
256
+
257
+ constructor(values: string[]) {
258
+ this.values = Array.from(new Set(values.filter(Boolean))).sort((a, b) => b.length - a.length);
259
+ this.retainChars = Math.max(0, ...this.values.map((value) => value.length));
260
+ }
261
+
262
+ write(text: string): string {
263
+ if (!text) return "";
264
+ if (this.values.length === 0) return text;
265
+
266
+ this.pending += text;
267
+ let output = "";
268
+
269
+ while (this.pending.length > 0) {
270
+ const match = this.findEarliestMatch();
271
+ if (match) {
272
+ output += this.pending.slice(0, match.index);
273
+ output += match.value.endsWith(".sock") ? "<control-socket>" : "<control-socket-dir>";
274
+ this.pending = this.pending.slice(match.index + match.value.length);
275
+ continue;
276
+ }
277
+
278
+ const emitLength = findSafeStringBoundary(this.pending, this.pending.length - this.retainChars);
279
+ if (emitLength <= 0) break;
280
+ output += this.pending.slice(0, emitLength);
281
+ this.pending = this.pending.slice(emitLength);
282
+ }
283
+
284
+ return output;
285
+ }
286
+
287
+ flush(): string {
288
+ const value = this.redact(this.pending);
289
+ this.pending = "";
290
+ return value;
291
+ }
292
+
293
+ private redact(text: string): string {
294
+ let redacted = text;
295
+ for (const value of this.values) {
296
+ const replacement = value.endsWith(".sock") ? "<control-socket>" : "<control-socket-dir>";
297
+ redacted = redacted.split(value).join(replacement);
298
+ }
299
+ return redacted;
300
+ }
301
+
302
+ private findEarliestMatch(): { index: number; value: string } | undefined {
303
+ let match: { index: number; value: string } | undefined;
304
+ for (const value of this.values) {
305
+ const index = this.pending.indexOf(value);
306
+ if (index === -1) continue;
307
+ if (!match || index < match.index || (index === match.index && value.length > match.value.length)) {
308
+ match = { index, value };
309
+ }
310
+ }
311
+ return match;
312
+ }
313
+ }
314
+
315
+ function findSafeStringBoundary(text: string, index: number): number {
316
+ const clamped = Math.max(0, Math.min(index, text.length));
317
+ if (clamped <= 0 || clamped >= text.length) {
318
+ return clamped;
319
+ }
320
+
321
+ const previous = text.charCodeAt(clamped - 1);
322
+ const current = text.charCodeAt(clamped);
323
+ if (isHighSurrogate(previous) && isLowSurrogate(current)) {
324
+ return clamped - 1;
325
+ }
326
+
327
+ return clamped;
328
+ }
329
+
330
+ function isHighSurrogate(value: number): boolean {
331
+ return value >= 0xd800 && value <= 0xdbff;
332
+ }
333
+
334
+ function isLowSurrogate(value: number): boolean {
335
+ return value >= 0xdc00 && value <= 0xdfff;
336
+ }