@evermore.work/adapter-utils 2026.509.0-canary.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 (93) hide show
  1. package/dist/billing.d.ts +2 -0
  2. package/dist/billing.d.ts.map +1 -0
  3. package/dist/billing.js +16 -0
  4. package/dist/billing.js.map +1 -0
  5. package/dist/billing.test.d.ts +2 -0
  6. package/dist/billing.test.d.ts.map +1 -0
  7. package/dist/billing.test.js +14 -0
  8. package/dist/billing.test.js.map +1 -0
  9. package/dist/command-managed-runtime.d.ts +45 -0
  10. package/dist/command-managed-runtime.d.ts.map +1 -0
  11. package/dist/command-managed-runtime.js +164 -0
  12. package/dist/command-managed-runtime.js.map +1 -0
  13. package/dist/command-managed-runtime.test.d.ts +2 -0
  14. package/dist/command-managed-runtime.test.d.ts.map +1 -0
  15. package/dist/command-managed-runtime.test.js +102 -0
  16. package/dist/command-managed-runtime.test.js.map +1 -0
  17. package/dist/command-redaction.d.ts +3 -0
  18. package/dist/command-redaction.d.ts.map +1 -0
  19. package/dist/command-redaction.js +17 -0
  20. package/dist/command-redaction.js.map +1 -0
  21. package/dist/execution-target-sandbox.test.d.ts +2 -0
  22. package/dist/execution-target-sandbox.test.d.ts.map +1 -0
  23. package/dist/execution-target-sandbox.test.js +392 -0
  24. package/dist/execution-target-sandbox.test.js.map +1 -0
  25. package/dist/execution-target.d.ts +150 -0
  26. package/dist/execution-target.d.ts.map +1 -0
  27. package/dist/execution-target.js +791 -0
  28. package/dist/execution-target.js.map +1 -0
  29. package/dist/execution-target.test.d.ts +2 -0
  30. package/dist/execution-target.test.d.ts.map +1 -0
  31. package/dist/execution-target.test.js +314 -0
  32. package/dist/execution-target.test.js.map +1 -0
  33. package/dist/index.d.ts +8 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/log-redaction.d.ts +9 -0
  38. package/dist/log-redaction.d.ts.map +1 -0
  39. package/dist/log-redaction.js +88 -0
  40. package/dist/log-redaction.js.map +1 -0
  41. package/dist/remote-execution-env.d.ts +2 -0
  42. package/dist/remote-execution-env.d.ts.map +1 -0
  43. package/dist/remote-execution-env.js +46 -0
  44. package/dist/remote-execution-env.js.map +1 -0
  45. package/dist/remote-managed-runtime.d.ts +31 -0
  46. package/dist/remote-managed-runtime.d.ts.map +1 -0
  47. package/dist/remote-managed-runtime.js +81 -0
  48. package/dist/remote-managed-runtime.js.map +1 -0
  49. package/dist/sandbox-callback-bridge.d.ts +132 -0
  50. package/dist/sandbox-callback-bridge.d.ts.map +1 -0
  51. package/dist/sandbox-callback-bridge.js +925 -0
  52. package/dist/sandbox-callback-bridge.js.map +1 -0
  53. package/dist/sandbox-callback-bridge.test.d.ts +2 -0
  54. package/dist/sandbox-callback-bridge.test.d.ts.map +1 -0
  55. package/dist/sandbox-callback-bridge.test.js +719 -0
  56. package/dist/sandbox-callback-bridge.test.js.map +1 -0
  57. package/dist/sandbox-managed-runtime.d.ts +54 -0
  58. package/dist/sandbox-managed-runtime.d.ts.map +1 -0
  59. package/dist/sandbox-managed-runtime.js +234 -0
  60. package/dist/sandbox-managed-runtime.js.map +1 -0
  61. package/dist/sandbox-managed-runtime.test.d.ts +2 -0
  62. package/dist/sandbox-managed-runtime.test.d.ts.map +1 -0
  63. package/dist/sandbox-managed-runtime.test.js +118 -0
  64. package/dist/sandbox-managed-runtime.test.js.map +1 -0
  65. package/dist/sandbox-shell.d.ts +2 -0
  66. package/dist/sandbox-shell.d.ts.map +1 -0
  67. package/dist/sandbox-shell.js +4 -0
  68. package/dist/sandbox-shell.js.map +1 -0
  69. package/dist/server-utils.d.ts +253 -0
  70. package/dist/server-utils.d.ts.map +1 -0
  71. package/dist/server-utils.js +1522 -0
  72. package/dist/server-utils.js.map +1 -0
  73. package/dist/server-utils.test.d.ts +2 -0
  74. package/dist/server-utils.test.d.ts.map +1 -0
  75. package/dist/server-utils.test.js +685 -0
  76. package/dist/server-utils.test.js.map +1 -0
  77. package/dist/session-compaction.d.ts +25 -0
  78. package/dist/session-compaction.d.ts.map +1 -0
  79. package/dist/session-compaction.js +154 -0
  80. package/dist/session-compaction.js.map +1 -0
  81. package/dist/ssh-fixture.test.d.ts +2 -0
  82. package/dist/ssh-fixture.test.d.ts.map +1 -0
  83. package/dist/ssh-fixture.test.js +214 -0
  84. package/dist/ssh-fixture.test.js.map +1 -0
  85. package/dist/ssh.d.ts +111 -0
  86. package/dist/ssh.d.ts.map +1 -0
  87. package/dist/ssh.js +1098 -0
  88. package/dist/ssh.js.map +1 -0
  89. package/dist/types.d.ts +465 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +5 -0
  92. package/dist/types.js.map +1 -0
  93. package/package.json +41 -0
package/dist/ssh.js ADDED
@@ -0,0 +1,1098 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { constants as fsConstants, createReadStream, createWriteStream, promises as fs } from "node:fs";
3
+ import net from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ export function createSshCommandManagedRuntimeRunner(input) {
7
+ const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd;
8
+ const maxBufferBytes = typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0
9
+ ? Math.trunc(input.maxBufferBytes)
10
+ : 1024 * 1024;
11
+ return {
12
+ execute: async (commandInput) => {
13
+ const startedAt = new Date().toISOString();
14
+ const command = commandInput.command.trim();
15
+ const args = commandInput.args ?? [];
16
+ const cwd = commandInput.cwd?.trim() || defaultCwd;
17
+ const envEntries = Object.entries(commandInput.env ?? {})
18
+ .filter((entry) => typeof entry[1] === "string");
19
+ const envPrefix = envEntries.length > 0
20
+ ? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `
21
+ : "";
22
+ const exportPrefix = envEntries.length > 0
23
+ ? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
24
+ : "";
25
+ const commandScript = command === "sh" || command === "bash"
26
+ ? args[0] === "-lc" && typeof args[1] === "string"
27
+ ? `${exportPrefix}${args[1]}`
28
+ : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
29
+ : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
30
+ const remoteCommand = `${command === "bash" ? "bash" : "sh"} -lc ${shellQuote(`cd ${shellQuote(cwd)} && ${commandScript}`)}`;
31
+ try {
32
+ const result = await runSshCommand(input.spec, remoteCommand, {
33
+ stdin: commandInput.stdin,
34
+ timeoutMs: commandInput.timeoutMs,
35
+ maxBuffer: maxBufferBytes,
36
+ });
37
+ if (result.stdout)
38
+ await commandInput.onLog?.("stdout", result.stdout);
39
+ if (result.stderr)
40
+ await commandInput.onLog?.("stderr", result.stderr);
41
+ return {
42
+ exitCode: 0,
43
+ signal: null,
44
+ timedOut: false,
45
+ stdout: result.stdout,
46
+ stderr: result.stderr,
47
+ pid: null,
48
+ startedAt,
49
+ };
50
+ }
51
+ catch (error) {
52
+ const failure = error;
53
+ const stdout = typeof failure.stdout === "string" ? failure.stdout : "";
54
+ const stderr = typeof failure.stderr === "string"
55
+ ? failure.stderr
56
+ : error instanceof Error
57
+ ? error.message
58
+ : String(error);
59
+ if (stdout)
60
+ await commandInput.onLog?.("stdout", stdout);
61
+ if (stderr)
62
+ await commandInput.onLog?.("stderr", stderr);
63
+ return {
64
+ exitCode: typeof failure.code === "number" ? failure.code : null,
65
+ signal: typeof failure.signal === "string" ? failure.signal : null,
66
+ timedOut: failure.killed === true,
67
+ stdout,
68
+ stderr,
69
+ pid: null,
70
+ startedAt,
71
+ };
72
+ }
73
+ },
74
+ };
75
+ }
76
+ export function shellQuote(value) {
77
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
78
+ }
79
+ function isValidShellEnvKey(value) {
80
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
81
+ }
82
+ export function parseSshRemoteExecutionSpec(value) {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
84
+ return null;
85
+ }
86
+ const parsed = value;
87
+ const host = typeof parsed.host === "string" ? parsed.host.trim() : "";
88
+ const username = typeof parsed.username === "string" ? parsed.username.trim() : "";
89
+ const remoteCwd = typeof parsed.remoteCwd === "string" ? parsed.remoteCwd.trim() : "";
90
+ const portValue = typeof parsed.port === "number" ? parsed.port : Number(parsed.port);
91
+ if (!host || !username || !remoteCwd || !Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
92
+ return null;
93
+ }
94
+ return {
95
+ host,
96
+ port: portValue,
97
+ username,
98
+ remoteCwd,
99
+ remoteWorkspacePath: typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0
100
+ ? parsed.remoteWorkspacePath.trim()
101
+ : remoteCwd,
102
+ privateKey: typeof parsed.privateKey === "string" && parsed.privateKey.length > 0 ? parsed.privateKey : null,
103
+ knownHosts: typeof parsed.knownHosts === "string" && parsed.knownHosts.length > 0 ? parsed.knownHosts : null,
104
+ strictHostKeyChecking: typeof parsed.strictHostKeyChecking === "boolean" ? parsed.strictHostKeyChecking : true,
105
+ };
106
+ }
107
+ async function execFileText(file, args, options = {}) {
108
+ return await new Promise((resolve, reject) => {
109
+ execFile(file, args, {
110
+ timeout: options.timeout ?? 15_000,
111
+ maxBuffer: options.maxBuffer ?? 1024 * 128,
112
+ }, (error, stdout, stderr) => {
113
+ if (error) {
114
+ reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" }));
115
+ return;
116
+ }
117
+ resolve({
118
+ stdout: stdout ?? "",
119
+ stderr: stderr ?? "",
120
+ });
121
+ });
122
+ });
123
+ }
124
+ async function spawnText(file, args, options = {}) {
125
+ return await new Promise((resolve, reject) => {
126
+ const child = spawn(file, args, {
127
+ stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
128
+ });
129
+ const maxBuffer = options.maxBuffer ?? 1024 * 128;
130
+ let stdout = "";
131
+ let stderr = "";
132
+ let settled = false;
133
+ let timedOut = false;
134
+ const finishReject = (error) => {
135
+ if (settled)
136
+ return;
137
+ settled = true;
138
+ error.stdout = stdout;
139
+ error.stderr = stderr;
140
+ error.killed = timedOut;
141
+ reject(error);
142
+ };
143
+ const append = (streamName, chunk) => {
144
+ const text = String(chunk);
145
+ if (streamName === "stdout") {
146
+ stdout += text;
147
+ }
148
+ else {
149
+ stderr += text;
150
+ }
151
+ if (Buffer.byteLength(stdout, "utf8") > maxBuffer || Buffer.byteLength(stderr, "utf8") > maxBuffer) {
152
+ child.kill("SIGTERM");
153
+ finishReject(Object.assign(new Error(`Process output exceeded maxBuffer of ${maxBuffer} bytes.`), {
154
+ code: null,
155
+ }));
156
+ }
157
+ };
158
+ let killEscalation = null;
159
+ const timeout = options.timeout && options.timeout > 0
160
+ ? setTimeout(() => {
161
+ timedOut = true;
162
+ child.kill("SIGTERM");
163
+ // Escalate to SIGKILL after a 5s grace window so a hung remote
164
+ // command that ignores SIGTERM cannot keep the child alive
165
+ // indefinitely.
166
+ killEscalation = setTimeout(() => {
167
+ try {
168
+ child.kill("SIGKILL");
169
+ }
170
+ catch {
171
+ // child may have already exited between the SIGTERM and the
172
+ // escalation — that's fine.
173
+ }
174
+ }, 5_000);
175
+ killEscalation.unref?.();
176
+ }, options.timeout)
177
+ : null;
178
+ const clearTimers = () => {
179
+ if (timeout)
180
+ clearTimeout(timeout);
181
+ if (killEscalation)
182
+ clearTimeout(killEscalation);
183
+ };
184
+ child.stdout?.on("data", (chunk) => {
185
+ append("stdout", chunk);
186
+ });
187
+ child.stderr?.on("data", (chunk) => {
188
+ append("stderr", chunk);
189
+ });
190
+ child.on("error", (error) => {
191
+ clearTimers();
192
+ finishReject(Object.assign(error, { code: null }));
193
+ });
194
+ child.on("close", (code, signal) => {
195
+ clearTimers();
196
+ if (settled)
197
+ return;
198
+ settled = true;
199
+ if (code === 0) {
200
+ resolve({ stdout, stderr });
201
+ return;
202
+ }
203
+ reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), {
204
+ stdout,
205
+ stderr,
206
+ code,
207
+ signal,
208
+ killed: timedOut,
209
+ }));
210
+ });
211
+ if (options.stdin != null && child.stdin) {
212
+ child.stdin.end(options.stdin);
213
+ }
214
+ });
215
+ }
216
+ async function runLocalGit(localDir, args, options = {}) {
217
+ return await execFileText("git", ["-C", localDir, ...args], options);
218
+ }
219
+ async function commandExists(command) {
220
+ return (await resolveCommandPath(command)) !== null;
221
+ }
222
+ async function resolveCommandPath(command) {
223
+ try {
224
+ const result = await execFileText("sh", ["-lc", `command -v ${shellQuote(command)}`], {
225
+ timeout: 5_000,
226
+ maxBuffer: 8 * 1024,
227
+ });
228
+ const resolved = result.stdout.trim().split("\n")[0]?.trim() ?? "";
229
+ return resolved.length > 0 ? resolved : null;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ async function withTempFile(prefix, contents, mode) {
236
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
237
+ const filePath = path.join(dir, "payload");
238
+ const normalizedContents = contents.endsWith("\n") ? contents : `${contents}\n`;
239
+ await fs.writeFile(filePath, normalizedContents, { mode, encoding: "utf8" });
240
+ return {
241
+ path: filePath,
242
+ cleanup: async () => {
243
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
244
+ },
245
+ };
246
+ }
247
+ async function createSshAuthArgs(config) {
248
+ const tempFiles = [];
249
+ const sshArgs = [
250
+ "-o",
251
+ "BatchMode=yes",
252
+ "-o",
253
+ "ConnectTimeout=10",
254
+ "-o",
255
+ `StrictHostKeyChecking=${config.strictHostKeyChecking ? "yes" : "no"}`,
256
+ ];
257
+ if (config.strictHostKeyChecking) {
258
+ if (config.knownHosts) {
259
+ const knownHosts = await withTempFile("evermore-ssh-known-hosts-", config.knownHosts, 0o600);
260
+ tempFiles.push(knownHosts.cleanup);
261
+ sshArgs.push("-o", `UserKnownHostsFile=${knownHosts.path}`);
262
+ }
263
+ }
264
+ else {
265
+ sshArgs.push("-o", "UserKnownHostsFile=/dev/null");
266
+ }
267
+ if (config.privateKey) {
268
+ const privateKey = await withTempFile("evermore-ssh-key-", config.privateKey, 0o600);
269
+ tempFiles.push(privateKey.cleanup);
270
+ sshArgs.push("-i", privateKey.path);
271
+ }
272
+ return {
273
+ args: sshArgs,
274
+ cleanup: async () => {
275
+ await Promise.all(tempFiles.map((cleanup) => cleanup()));
276
+ },
277
+ };
278
+ }
279
+ function tarExcludeArgs(exclude) {
280
+ const combined = ["._*", ...(exclude ?? [])];
281
+ return combined.flatMap((entry) => ["--exclude", entry]);
282
+ }
283
+ function tarSpawnEnv() {
284
+ return {
285
+ ...process.env,
286
+ // Prevent macOS bsdtar from emitting AppleDouble metadata files like ._README.md.
287
+ COPYFILE_DISABLE: "1",
288
+ };
289
+ }
290
+ async function runSshScript(config, script, options = {}) {
291
+ return await runSshCommand(config, `sh -lc ${shellQuote(script)}`, options);
292
+ }
293
+ async function clearLocalDirectory(localDir, preserveEntries = []) {
294
+ await fs.mkdir(localDir, { recursive: true });
295
+ const preserve = new Set(preserveEntries);
296
+ const entries = await fs.readdir(localDir);
297
+ await Promise.all(entries
298
+ .filter((entry) => !preserve.has(entry))
299
+ .map((entry) => fs.rm(path.join(localDir, entry), { recursive: true, force: true })));
300
+ }
301
+ async function copyDirectoryContents(sourceDir, targetDir) {
302
+ await fs.mkdir(targetDir, { recursive: true });
303
+ const entries = await fs.readdir(sourceDir);
304
+ await Promise.all(entries.map(async (entry) => {
305
+ await fs.cp(path.join(sourceDir, entry), path.join(targetDir, entry), {
306
+ recursive: true,
307
+ force: true,
308
+ preserveTimestamps: true,
309
+ });
310
+ }));
311
+ }
312
+ async function readLocalGitWorkspaceSnapshot(localDir) {
313
+ try {
314
+ const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
315
+ timeout: 10_000,
316
+ maxBuffer: 16 * 1024,
317
+ });
318
+ if (insideWorkTree.stdout.trim() !== "true") {
319
+ return null;
320
+ }
321
+ const [headCommitResult, branchResult, deletedResult] = await Promise.all([
322
+ runLocalGit(localDir, ["rev-parse", "HEAD"], {
323
+ timeout: 10_000,
324
+ maxBuffer: 16 * 1024,
325
+ }),
326
+ runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], {
327
+ timeout: 10_000,
328
+ maxBuffer: 16 * 1024,
329
+ }),
330
+ runLocalGit(localDir, ["ls-files", "--deleted", "-z"], {
331
+ timeout: 10_000,
332
+ maxBuffer: 256 * 1024,
333
+ }),
334
+ ]);
335
+ const branchName = branchResult.stdout.trim();
336
+ return {
337
+ headCommit: headCommitResult.stdout.trim(),
338
+ branchName: branchName && branchName !== "HEAD" ? branchName : null,
339
+ deletedPaths: deletedResult.stdout
340
+ .split("\0")
341
+ .map((entry) => entry.trim())
342
+ .filter(Boolean),
343
+ };
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ }
349
+ async function streamLocalFileToSsh(input) {
350
+ const auth = await createSshAuthArgs(input.spec);
351
+ const sshArgs = [
352
+ ...auth.args,
353
+ "-p",
354
+ String(input.spec.port),
355
+ `${input.spec.username}@${input.spec.host}`,
356
+ `sh -lc ${shellQuote(input.remoteScript)}`,
357
+ ];
358
+ await new Promise((resolve, reject) => {
359
+ const source = createReadStream(input.localFile);
360
+ const ssh = spawn("ssh", sshArgs, {
361
+ stdio: ["pipe", "ignore", "pipe"],
362
+ });
363
+ let sshStderr = "";
364
+ let settled = false;
365
+ const fail = (error) => {
366
+ if (settled)
367
+ return;
368
+ settled = true;
369
+ source.destroy();
370
+ ssh.kill("SIGTERM");
371
+ reject(error);
372
+ };
373
+ ssh.stderr?.on("data", (chunk) => {
374
+ sshStderr += String(chunk);
375
+ });
376
+ source.on("error", fail);
377
+ ssh.on("error", fail);
378
+ source.pipe(ssh.stdin ?? null);
379
+ ssh.on("close", (code) => {
380
+ if (settled)
381
+ return;
382
+ settled = true;
383
+ if ((code ?? 0) !== 0) {
384
+ reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`));
385
+ return;
386
+ }
387
+ resolve();
388
+ });
389
+ }).finally(auth.cleanup);
390
+ }
391
+ async function streamSshToLocalFile(input) {
392
+ const auth = await createSshAuthArgs(input.spec);
393
+ const sshArgs = [
394
+ ...auth.args,
395
+ "-p",
396
+ String(input.spec.port),
397
+ `${input.spec.username}@${input.spec.host}`,
398
+ `sh -lc ${shellQuote(input.remoteScript)}`,
399
+ ];
400
+ await new Promise((resolve, reject) => {
401
+ const ssh = spawn("ssh", sshArgs, {
402
+ stdio: ["ignore", "pipe", "pipe"],
403
+ });
404
+ const sink = createWriteStream(input.localFile, { mode: 0o600 });
405
+ let sshStderr = "";
406
+ let settled = false;
407
+ const fail = (error) => {
408
+ if (settled)
409
+ return;
410
+ settled = true;
411
+ ssh.kill("SIGTERM");
412
+ sink.destroy();
413
+ reject(error);
414
+ };
415
+ ssh.stdout?.pipe(sink);
416
+ ssh.stderr?.on("data", (chunk) => {
417
+ sshStderr += String(chunk);
418
+ });
419
+ ssh.on("error", fail);
420
+ sink.on("error", fail);
421
+ ssh.on("close", (code) => {
422
+ sink.end(() => {
423
+ if (settled)
424
+ return;
425
+ settled = true;
426
+ if ((code ?? 0) !== 0) {
427
+ reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`));
428
+ return;
429
+ }
430
+ resolve();
431
+ });
432
+ });
433
+ }).finally(auth.cleanup);
434
+ }
435
+ async function importGitWorkspaceToSsh(input) {
436
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "evermore-ssh-bundle-"));
437
+ const bundlePath = path.join(bundleDir, "workspace.bundle");
438
+ const tempRef = "refs/evermore/ssh-sync/import";
439
+ try {
440
+ await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
441
+ timeout: 10_000,
442
+ maxBuffer: 16 * 1024,
443
+ });
444
+ await runLocalGit(input.localDir, ["bundle", "create", bundlePath, tempRef], {
445
+ timeout: 60_000,
446
+ maxBuffer: 1024 * 1024,
447
+ });
448
+ const remoteSetupScript = [
449
+ "set -e",
450
+ `mkdir -p ${shellQuote(path.posix.join(input.remoteDir, ".evermore-runtime"))}`,
451
+ `tmp_bundle=$(mktemp ${shellQuote(path.posix.join(input.remoteDir, ".evermore-runtime", "import-XXXXXX.bundle"))})`,
452
+ 'trap \'rm -f "$tmp_bundle"\' EXIT',
453
+ 'cat > "$tmp_bundle"',
454
+ `if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`,
455
+ `git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`,
456
+ input.snapshot.branchName
457
+ ? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null`
458
+ : `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
459
+ `git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
460
+ `git -C ${shellQuote(input.remoteDir)} clean -fdx -e .evermore-runtime >/dev/null`,
461
+ ].join("\n");
462
+ await streamLocalFileToSsh({
463
+ spec: input.spec,
464
+ localFile: bundlePath,
465
+ remoteScript: remoteSetupScript,
466
+ });
467
+ }
468
+ finally {
469
+ await runLocalGit(input.localDir, ["update-ref", "-d", tempRef], {
470
+ timeout: 10_000,
471
+ maxBuffer: 16 * 1024,
472
+ }).catch(() => undefined);
473
+ await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined);
474
+ }
475
+ }
476
+ async function exportGitWorkspaceFromSsh(input) {
477
+ const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "evermore-ssh-bundle-"));
478
+ const bundlePath = path.join(bundleDir, "workspace.bundle");
479
+ const importedRef = "refs/evermore/ssh-sync/imported";
480
+ try {
481
+ const exportScript = [
482
+ "set -e",
483
+ `git -C ${shellQuote(input.remoteDir)} update-ref refs/evermore/ssh-sync/export HEAD`,
484
+ `mkdir -p ${shellQuote(path.posix.join(input.remoteDir, ".evermore-runtime"))}`,
485
+ `tmp_bundle=$(mktemp ${shellQuote(path.posix.join(input.remoteDir, ".evermore-runtime", "export-XXXXXX.bundle"))})`,
486
+ 'cleanup() { rm -f "$tmp_bundle"; git -C ' + shellQuote(input.remoteDir) + ' update-ref -d refs/evermore/ssh-sync/export >/dev/null 2>&1 || true; }',
487
+ 'trap cleanup EXIT',
488
+ `git -C ${shellQuote(input.remoteDir)} bundle create "$tmp_bundle" refs/evermore/ssh-sync/export >/dev/null`,
489
+ 'cat "$tmp_bundle"',
490
+ ].join("\n");
491
+ await streamSshToLocalFile({
492
+ spec: input.spec,
493
+ remoteScript: exportScript,
494
+ localFile: bundlePath,
495
+ });
496
+ await runLocalGit(input.localDir, ["fetch", "--force", bundlePath, `refs/evermore/ssh-sync/export:${importedRef}`], {
497
+ timeout: 60_000,
498
+ maxBuffer: 1024 * 1024,
499
+ });
500
+ await runLocalGit(input.localDir, ["reset", "--hard", importedRef], {
501
+ timeout: 60_000,
502
+ maxBuffer: 1024 * 1024,
503
+ });
504
+ }
505
+ finally {
506
+ await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
507
+ timeout: 10_000,
508
+ maxBuffer: 16 * 1024,
509
+ }).catch(() => undefined);
510
+ await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined);
511
+ }
512
+ }
513
+ async function clearRemoteDirectory(input) {
514
+ const preservePatterns = (input.preserveEntries ?? [])
515
+ .map((entry) => `! -name ${shellQuote(entry)}`)
516
+ .join(" ");
517
+ const script = [
518
+ "set -e",
519
+ `mkdir -p ${shellQuote(input.remoteDir)}`,
520
+ `find ${shellQuote(input.remoteDir)} -mindepth 1 -maxdepth 1 ${preservePatterns} -exec rm -rf -- {} +`,
521
+ ].join("\n");
522
+ await runSshScript(input.spec, script, {
523
+ timeoutMs: 30_000,
524
+ maxBuffer: 256 * 1024,
525
+ });
526
+ }
527
+ async function removeDeletedPathsOnSsh(input) {
528
+ if (input.deletedPaths.length === 0)
529
+ return;
530
+ const quotedPaths = input.deletedPaths.map((entry) => shellQuote(entry)).join(" ");
531
+ const script = `cd ${shellQuote(input.remoteDir)} && rm -rf -- ${quotedPaths}`;
532
+ await runSshScript(input.spec, script, {
533
+ timeoutMs: 30_000,
534
+ maxBuffer: 256 * 1024,
535
+ });
536
+ }
537
+ async function allocateLoopbackPort(host) {
538
+ return await new Promise((resolve, reject) => {
539
+ const server = net.createServer();
540
+ server.once("error", reject);
541
+ server.listen(0, host, () => {
542
+ const address = server.address();
543
+ if (!address || typeof address === "string") {
544
+ server.close(() => reject(new Error("Failed to allocate a loopback port.")));
545
+ return;
546
+ }
547
+ const { port } = address;
548
+ server.close((error) => {
549
+ if (error) {
550
+ reject(error);
551
+ return;
552
+ }
553
+ resolve(port);
554
+ });
555
+ });
556
+ });
557
+ }
558
+ async function waitForCondition(fn, options = {}) {
559
+ const timeoutAt = Date.now() + (options.timeoutMs ?? 10_000);
560
+ const intervalMs = options.intervalMs ?? 200;
561
+ let lastError = null;
562
+ while (Date.now() < timeoutAt) {
563
+ try {
564
+ await fn();
565
+ return;
566
+ }
567
+ catch (error) {
568
+ lastError = error;
569
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
570
+ }
571
+ }
572
+ throw lastError instanceof Error
573
+ ? lastError
574
+ : new Error("Timed out waiting for SSH fixture readiness.");
575
+ }
576
+ async function isPidRunning(pid) {
577
+ try {
578
+ process.kill(pid, 0);
579
+ return true;
580
+ }
581
+ catch {
582
+ return false;
583
+ }
584
+ }
585
+ async function readProcessCommand(pid) {
586
+ for (const format of ["command=", "args="]) {
587
+ try {
588
+ const result = await execFileText("ps", ["-o", format, "-p", String(pid)], {
589
+ timeout: 5_000,
590
+ maxBuffer: 16 * 1024,
591
+ });
592
+ const command = result.stdout.trim();
593
+ if (command.length > 0) {
594
+ return command;
595
+ }
596
+ }
597
+ catch {
598
+ continue;
599
+ }
600
+ }
601
+ return null;
602
+ }
603
+ async function isSshEnvLabFixtureProcess(state) {
604
+ if (!(await isPidRunning(state.pid))) {
605
+ return false;
606
+ }
607
+ const command = await readProcessCommand(state.pid);
608
+ if (!command) {
609
+ return false;
610
+ }
611
+ return command.includes(state.sshdConfigPath);
612
+ }
613
+ export async function getSshEnvLabSupport() {
614
+ for (const command of ["ssh", "sshd", "ssh-keygen"]) {
615
+ if (!(await commandExists(command))) {
616
+ return {
617
+ supported: false,
618
+ reason: `Missing required command: ${command}`,
619
+ };
620
+ }
621
+ }
622
+ return {
623
+ supported: true,
624
+ reason: null,
625
+ };
626
+ }
627
+ export function buildKnownHostsEntry(input) {
628
+ return `[${input.host}]:${input.port} ${input.publicKey.trim()}`;
629
+ }
630
+ export async function runSshCommand(config, remoteCommand, options = {}) {
631
+ let cleanup = () => Promise.resolve();
632
+ try {
633
+ const auth = await createSshAuthArgs(config);
634
+ cleanup = auth.cleanup;
635
+ const sshArgs = [...auth.args];
636
+ const envEntries = Object.entries(options.env ?? {})
637
+ .filter((entry) => typeof entry[1] === "string");
638
+ for (const [key] of envEntries) {
639
+ if (!isValidShellEnvKey(key)) {
640
+ throw new Error(`Invalid SSH environment variable key: ${key}`);
641
+ }
642
+ }
643
+ // Mirror buildSshSpawnTarget: source login profiles first, then run
644
+ // `env KEY=VAL cmd` so user-supplied identity overrides win over anything
645
+ // a profile re-exports. Without this, a remote profile that resets HOME
646
+ // / NVM_DIR / etc. would silently undo the explicit env passed in here.
647
+ const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
648
+ const remoteScript = [
649
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
650
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
651
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
652
+ envArgs.length > 0
653
+ ? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}`
654
+ : `exec sh -c ${shellQuote(remoteCommand)}`,
655
+ ].join(" && ");
656
+ sshArgs.push("-p", String(config.port), `${config.username}@${config.host}`, `sh -lc ${shellQuote(remoteScript)}`);
657
+ return options.stdin != null
658
+ ? await spawnText("ssh", sshArgs, {
659
+ stdin: options.stdin,
660
+ timeout: options.timeoutMs ?? 15_000,
661
+ maxBuffer: options.maxBuffer ?? 1024 * 128,
662
+ })
663
+ : await execFileText("ssh", sshArgs, {
664
+ timeout: options.timeoutMs ?? 15_000,
665
+ maxBuffer: options.maxBuffer ?? 1024 * 128,
666
+ });
667
+ }
668
+ finally {
669
+ await cleanup();
670
+ }
671
+ }
672
+ export async function buildSshSpawnTarget(input) {
673
+ for (const key of Object.keys(input.env)) {
674
+ if (!isValidShellEnvKey(key)) {
675
+ throw new Error(`Invalid SSH environment variable key: ${key}`);
676
+ }
677
+ }
678
+ const auth = await createSshAuthArgs(input.spec);
679
+ const sshArgs = [...auth.args];
680
+ const envArgs = Object.entries(input.env)
681
+ .filter((entry) => typeof entry[1] === "string")
682
+ .map(([key, value]) => `${key}=${shellQuote(value)}`);
683
+ const remoteCommandParts = [shellQuote(input.command), ...input.args.map((arg) => shellQuote(arg))].join(" ");
684
+ const remoteScript = [
685
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
686
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
687
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
688
+ 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
689
+ '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
690
+ `cd ${shellQuote(input.spec.remoteCwd)}`,
691
+ envArgs.length > 0
692
+ ? `exec env ${envArgs.join(" ")} ${remoteCommandParts}`
693
+ : `exec ${remoteCommandParts}`,
694
+ ].join(" && ");
695
+ sshArgs.push("-p", String(input.spec.port), `${input.spec.username}@${input.spec.host}`, `sh -lc ${shellQuote(remoteScript)}`);
696
+ return {
697
+ command: "ssh",
698
+ args: sshArgs,
699
+ cleanup: auth.cleanup,
700
+ };
701
+ }
702
+ export async function syncDirectoryToSsh(input) {
703
+ const auth = await createSshAuthArgs(input.spec);
704
+ const sshArgs = [
705
+ ...auth.args,
706
+ "-p",
707
+ String(input.spec.port),
708
+ `${input.spec.username}@${input.spec.host}`,
709
+ `sh -lc ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`,
710
+ ];
711
+ await new Promise((resolve, reject) => {
712
+ const tarArgs = [
713
+ ...(input.followSymlinks ? ["-h"] : []),
714
+ "-C",
715
+ input.localDir,
716
+ ...tarExcludeArgs(input.exclude),
717
+ "-cf",
718
+ "-",
719
+ ".",
720
+ ];
721
+ const tar = spawn("tar", tarArgs, {
722
+ stdio: ["ignore", "pipe", "pipe"],
723
+ env: tarSpawnEnv(),
724
+ });
725
+ const ssh = spawn("ssh", sshArgs, {
726
+ stdio: ["pipe", "ignore", "pipe"],
727
+ });
728
+ let tarStderr = "";
729
+ let sshStderr = "";
730
+ let settled = false;
731
+ let tarExited = false;
732
+ let sshExited = false;
733
+ let tarExitCode = null;
734
+ let sshExitCode = null;
735
+ const maybeFinish = () => {
736
+ if (settled || !tarExited || !sshExited) {
737
+ return;
738
+ }
739
+ settled = true;
740
+ if ((tarExitCode ?? 0) !== 0) {
741
+ reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`));
742
+ return;
743
+ }
744
+ if ((sshExitCode ?? 0) !== 0) {
745
+ reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`));
746
+ return;
747
+ }
748
+ resolve();
749
+ };
750
+ const fail = (error) => {
751
+ if (settled) {
752
+ return;
753
+ }
754
+ settled = true;
755
+ tar.kill("SIGTERM");
756
+ ssh.kill("SIGTERM");
757
+ reject(error);
758
+ };
759
+ tar.stdout?.pipe(ssh.stdin ?? null);
760
+ tar.stderr?.on("data", (chunk) => {
761
+ tarStderr += String(chunk);
762
+ });
763
+ ssh.stderr?.on("data", (chunk) => {
764
+ sshStderr += String(chunk);
765
+ });
766
+ tar.on("error", fail);
767
+ ssh.on("error", fail);
768
+ tar.on("close", (code) => {
769
+ tarExited = true;
770
+ tarExitCode = code;
771
+ maybeFinish();
772
+ });
773
+ ssh.on("close", (code) => {
774
+ sshExited = true;
775
+ sshExitCode = code;
776
+ maybeFinish();
777
+ });
778
+ }).finally(auth.cleanup);
779
+ }
780
+ export async function syncDirectoryFromSsh(input) {
781
+ const auth = await createSshAuthArgs(input.spec);
782
+ const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "evermore-ssh-sync-back-"));
783
+ const remoteTarScript = [
784
+ `cd ${shellQuote(input.remoteDir)}`,
785
+ `tar ${[...tarExcludeArgs(input.exclude).map(shellQuote), "-cf", "-", "."].join(" ")}`,
786
+ ].join(" && ");
787
+ const sshArgs = [
788
+ ...auth.args,
789
+ "-p",
790
+ String(input.spec.port),
791
+ `${input.spec.username}@${input.spec.host}`,
792
+ `sh -lc ${shellQuote(remoteTarScript)}`,
793
+ ];
794
+ try {
795
+ await new Promise((resolve, reject) => {
796
+ const ssh = spawn("ssh", sshArgs, {
797
+ stdio: ["ignore", "pipe", "pipe"],
798
+ });
799
+ const tar = spawn("tar", ["-xf", "-", "-C", stagingDir], {
800
+ stdio: ["pipe", "ignore", "pipe"],
801
+ env: tarSpawnEnv(),
802
+ });
803
+ let sshStderr = "";
804
+ let tarStderr = "";
805
+ let settled = false;
806
+ let sshExited = false;
807
+ let tarExited = false;
808
+ let sshExitCode = null;
809
+ let tarExitCode = null;
810
+ const maybeFinish = () => {
811
+ if (settled || !sshExited || !tarExited)
812
+ return;
813
+ settled = true;
814
+ if ((sshExitCode ?? 0) !== 0) {
815
+ reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`));
816
+ return;
817
+ }
818
+ if ((tarExitCode ?? 0) !== 0) {
819
+ reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`));
820
+ return;
821
+ }
822
+ resolve();
823
+ };
824
+ const fail = (error) => {
825
+ if (settled)
826
+ return;
827
+ settled = true;
828
+ ssh.kill("SIGTERM");
829
+ tar.kill("SIGTERM");
830
+ reject(error);
831
+ };
832
+ ssh.stdout?.pipe(tar.stdin ?? null);
833
+ ssh.stderr?.on("data", (chunk) => {
834
+ sshStderr += String(chunk);
835
+ });
836
+ tar.stderr?.on("data", (chunk) => {
837
+ tarStderr += String(chunk);
838
+ });
839
+ ssh.on("error", fail);
840
+ tar.on("error", fail);
841
+ ssh.on("close", (code) => {
842
+ sshExited = true;
843
+ sshExitCode = code;
844
+ maybeFinish();
845
+ });
846
+ tar.on("close", (code) => {
847
+ tarExited = true;
848
+ tarExitCode = code;
849
+ maybeFinish();
850
+ });
851
+ });
852
+ await clearLocalDirectory(input.localDir, input.preserveLocalEntries);
853
+ await copyDirectoryContents(stagingDir, input.localDir);
854
+ }
855
+ finally {
856
+ await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
857
+ await auth.cleanup();
858
+ }
859
+ }
860
+ export async function prepareWorkspaceForSshExecution(input) {
861
+ const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
862
+ const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
863
+ if (gitSnapshot) {
864
+ await importGitWorkspaceToSsh({
865
+ spec: input.spec,
866
+ localDir: input.localDir,
867
+ remoteDir,
868
+ snapshot: gitSnapshot,
869
+ });
870
+ await syncDirectoryToSsh({
871
+ spec: input.spec,
872
+ localDir: input.localDir,
873
+ remoteDir,
874
+ exclude: [".git", ".evermore-runtime"],
875
+ });
876
+ await removeDeletedPathsOnSsh({
877
+ spec: input.spec,
878
+ remoteDir,
879
+ deletedPaths: gitSnapshot.deletedPaths,
880
+ });
881
+ return;
882
+ }
883
+ await clearRemoteDirectory({
884
+ spec: input.spec,
885
+ remoteDir,
886
+ preserveEntries: [".evermore-runtime"],
887
+ });
888
+ await syncDirectoryToSsh({
889
+ spec: input.spec,
890
+ localDir: input.localDir,
891
+ remoteDir,
892
+ exclude: [".evermore-runtime"],
893
+ });
894
+ }
895
+ export async function restoreWorkspaceFromSshExecution(input) {
896
+ const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
897
+ const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
898
+ if (gitSnapshot) {
899
+ await exportGitWorkspaceFromSsh({
900
+ spec: input.spec,
901
+ remoteDir,
902
+ localDir: input.localDir,
903
+ });
904
+ await syncDirectoryFromSsh({
905
+ spec: input.spec,
906
+ remoteDir,
907
+ localDir: input.localDir,
908
+ exclude: [".git", ".evermore-runtime"],
909
+ preserveLocalEntries: [".git"],
910
+ });
911
+ return;
912
+ }
913
+ await syncDirectoryFromSsh({
914
+ spec: input.spec,
915
+ remoteDir,
916
+ localDir: input.localDir,
917
+ exclude: [".evermore-runtime"],
918
+ });
919
+ }
920
+ export async function ensureSshWorkspaceReady(config) {
921
+ const result = await runSshCommand(config, `sh -lc ${shellQuote(`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`)}`);
922
+ return {
923
+ remoteCwd: result.stdout.trim(),
924
+ };
925
+ }
926
+ export async function readSshEnvLabFixtureState(statePath) {
927
+ try {
928
+ const raw = JSON.parse(await fs.readFile(statePath, "utf8"));
929
+ if (!raw || raw.kind !== "ssh_openbsd")
930
+ return null;
931
+ return raw;
932
+ }
933
+ catch {
934
+ return null;
935
+ }
936
+ }
937
+ export async function stopSshEnvLabFixture(statePath) {
938
+ const state = await readSshEnvLabFixtureState(statePath);
939
+ if (!state)
940
+ return false;
941
+ if (await isSshEnvLabFixtureProcess(state)) {
942
+ process.kill(state.pid, "SIGTERM");
943
+ await waitForCondition(async () => {
944
+ if (await isSshEnvLabFixtureProcess(state)) {
945
+ throw new Error("SSH fixture process is still running.");
946
+ }
947
+ }, { timeoutMs: 5_000, intervalMs: 100 });
948
+ }
949
+ await fs.rm(state.rootDir, { recursive: true, force: true }).catch(() => undefined);
950
+ return true;
951
+ }
952
+ export async function startSshEnvLabFixture(input) {
953
+ const existing = await readSshEnvLabFixtureState(input.statePath);
954
+ if (existing && await isSshEnvLabFixtureProcess(existing)) {
955
+ return existing;
956
+ }
957
+ if (existing) {
958
+ await fs.rm(existing.rootDir, { recursive: true, force: true }).catch(() => undefined);
959
+ }
960
+ const support = await getSshEnvLabSupport();
961
+ if (!support.supported) {
962
+ throw new Error(`SSH env-lab fixture is unavailable: ${support.reason}`);
963
+ }
964
+ const sshdPath = await resolveCommandPath("sshd");
965
+ if (!sshdPath) {
966
+ throw new Error("SSH env-lab fixture is unavailable: missing required command: sshd");
967
+ }
968
+ const bindHost = input.bindHost ?? "127.0.0.1";
969
+ const host = input.host ?? bindHost;
970
+ const rootDir = path.dirname(input.statePath);
971
+ await fs.mkdir(rootDir, { recursive: true });
972
+ const username = os.userInfo().username;
973
+ const port = await allocateLoopbackPort(bindHost);
974
+ const workspaceDir = path.join(rootDir, "workspace");
975
+ const clientPrivateKeyPath = path.join(rootDir, "client_key");
976
+ const clientPublicKeyPath = `${clientPrivateKeyPath}.pub`;
977
+ const hostPrivateKeyPath = path.join(rootDir, "host_key");
978
+ const hostPublicKeyPath = `${hostPrivateKeyPath}.pub`;
979
+ const authorizedKeysPath = path.join(rootDir, "authorized_keys");
980
+ const knownHostsPath = path.join(rootDir, "known_hosts");
981
+ const sshdConfigPath = path.join(rootDir, "sshd_config");
982
+ const sshdLogPath = path.join(rootDir, "sshd.log");
983
+ const sshdPidPath = path.join(rootDir, "sshd.pid");
984
+ await fs.mkdir(workspaceDir, { recursive: true });
985
+ await execFileText("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", clientPrivateKeyPath], {
986
+ timeout: 15_000,
987
+ });
988
+ await execFileText("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", hostPrivateKeyPath], {
989
+ timeout: 15_000,
990
+ });
991
+ await fs.copyFile(clientPublicKeyPath, authorizedKeysPath);
992
+ const hostPublicKey = (await execFileText("ssh-keygen", ["-y", "-f", hostPrivateKeyPath], {
993
+ timeout: 15_000,
994
+ })).stdout.trim();
995
+ await fs.writeFile(knownHostsPath, `${buildKnownHostsEntry({ host, port, publicKey: hostPublicKey })}\n`, { mode: 0o600 });
996
+ await fs.writeFile(sshdConfigPath, [
997
+ `Port ${port}`,
998
+ `ListenAddress ${bindHost}`,
999
+ `HostKey ${hostPrivateKeyPath}`,
1000
+ `PidFile ${sshdPidPath}`,
1001
+ `AuthorizedKeysFile ${authorizedKeysPath}`,
1002
+ "PasswordAuthentication no",
1003
+ "ChallengeResponseAuthentication no",
1004
+ "KbdInteractiveAuthentication no",
1005
+ "PubkeyAuthentication yes",
1006
+ "PermitRootLogin no",
1007
+ "UsePAM no",
1008
+ "StrictModes no",
1009
+ `AllowUsers ${username}`,
1010
+ "LogLevel VERBOSE",
1011
+ "PrintMotd no",
1012
+ "UseDNS no",
1013
+ "Subsystem sftp internal-sftp",
1014
+ "",
1015
+ ].join("\n"), { mode: 0o600 });
1016
+ const child = spawn(sshdPath, ["-D", "-f", sshdConfigPath, "-E", sshdLogPath], {
1017
+ detached: true,
1018
+ stdio: "ignore",
1019
+ });
1020
+ child.unref();
1021
+ const state = {
1022
+ kind: "ssh_openbsd",
1023
+ bindHost,
1024
+ host,
1025
+ port,
1026
+ username,
1027
+ rootDir,
1028
+ workspaceDir,
1029
+ statePath: input.statePath,
1030
+ pid: child.pid ?? 0,
1031
+ createdAt: new Date().toISOString(),
1032
+ clientPrivateKeyPath,
1033
+ clientPublicKeyPath,
1034
+ hostPrivateKeyPath,
1035
+ hostPublicKeyPath,
1036
+ authorizedKeysPath,
1037
+ knownHostsPath,
1038
+ sshdConfigPath,
1039
+ sshdLogPath,
1040
+ };
1041
+ if (!state.pid) {
1042
+ throw new Error("Failed to start SSH env-lab fixture.");
1043
+ }
1044
+ try {
1045
+ await waitForCondition(async () => {
1046
+ if (!(await isPidRunning(state.pid))) {
1047
+ const logOutput = await fs.readFile(sshdLogPath, "utf8").catch(() => "");
1048
+ throw new Error(logOutput || "SSH env-lab fixture exited before becoming ready.");
1049
+ }
1050
+ const config = await buildSshEnvLabFixtureConfig(state);
1051
+ await ensureSshWorkspaceReady(config);
1052
+ }, { timeoutMs: 10_000, intervalMs: 250 });
1053
+ await fs.writeFile(input.statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
1054
+ return state;
1055
+ }
1056
+ catch (error) {
1057
+ if (await isPidRunning(state.pid)) {
1058
+ process.kill(state.pid, "SIGTERM");
1059
+ }
1060
+ await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
1061
+ throw error;
1062
+ }
1063
+ }
1064
+ export async function buildSshEnvLabFixtureConfig(state) {
1065
+ const [privateKey, knownHosts] = await Promise.all([
1066
+ fs.readFile(state.clientPrivateKeyPath, "utf8"),
1067
+ fs.readFile(state.knownHostsPath, "utf8"),
1068
+ ]);
1069
+ return {
1070
+ host: state.host,
1071
+ port: state.port,
1072
+ username: state.username,
1073
+ remoteWorkspacePath: state.workspaceDir,
1074
+ privateKey,
1075
+ knownHosts,
1076
+ strictHostKeyChecking: true,
1077
+ };
1078
+ }
1079
+ export async function readSshEnvLabFixtureStatus(statePath) {
1080
+ const state = await readSshEnvLabFixtureState(statePath);
1081
+ if (!state) {
1082
+ return { running: false, state: null };
1083
+ }
1084
+ return {
1085
+ running: await isSshEnvLabFixtureProcess(state),
1086
+ state,
1087
+ };
1088
+ }
1089
+ export async function fileExists(filePath) {
1090
+ try {
1091
+ await fs.access(filePath, fsConstants.F_OK);
1092
+ return true;
1093
+ }
1094
+ catch {
1095
+ return false;
1096
+ }
1097
+ }
1098
+ //# sourceMappingURL=ssh.js.map