toolcraft 0.0.24 → 0.0.26

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 (43) hide show
  1. package/dist/cli.js +11 -9
  2. package/dist/error-report.js +14 -11
  3. package/dist/redaction.d.ts +4 -0
  4. package/dist/redaction.js +70 -0
  5. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +33 -9
  6. package/node_modules/@poe-code/config-mutations/dist/formats/json.d.ts +2 -1
  7. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +36 -9
  8. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +2 -0
  9. package/node_modules/@poe-code/design-system/dist/components/browser.js +1 -1
  10. package/node_modules/@poe-code/design-system/dist/explorer/actions.js +1 -1
  11. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +11 -1
  12. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +64 -8
  13. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +9 -11
  14. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +18 -8
  15. package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +11 -18
  16. package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +2 -10
  17. package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +32 -22
  18. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +5 -9
  19. package/node_modules/@poe-code/design-system/dist/explorer/render/text.d.ts +12 -0
  20. package/node_modules/@poe-code/design-system/dist/explorer/render/text.js +81 -0
  21. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  22. package/node_modules/@poe-code/design-system/dist/explorer/state.js +2 -0
  23. package/node_modules/@poe-code/design-system/dist/prompts/index.js +3 -3
  24. package/node_modules/@poe-code/process-runner/dist/docker/args.d.ts +1 -0
  25. package/node_modules/@poe-code/process-runner/dist/docker/args.js +11 -3
  26. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +170 -36
  27. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +66 -9
  28. package/node_modules/@poe-code/process-runner/dist/docker/env-file.d.ts +6 -0
  29. package/node_modules/@poe-code/process-runner/dist/docker/env-file.js +49 -0
  30. package/node_modules/@poe-code/process-runner/dist/host/host-runner.js +5 -4
  31. package/node_modules/@poe-code/process-runner/dist/types.d.ts +3 -0
  32. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.d.ts +5 -1
  33. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +29 -10
  34. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +1 -0
  35. package/node_modules/auth-store/dist/encrypted-file-store.js +36 -2
  36. package/node_modules/auth-store/dist/keychain-store.js +20 -1
  37. package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +6 -3
  38. package/node_modules/tiny-mcp-client/dist/internal.d.ts +3 -0
  39. package/node_modules/tiny-mcp-client/dist/internal.js +39 -14
  40. package/node_modules/tiny-mcp-client/src/internal.ts +45 -17
  41. package/node_modules/tiny-mcp-client/src/mcp-client-sdk.test.ts +32 -0
  42. package/node_modules/tiny-mcp-client/src/transports.test.ts +68 -0
  43. package/package.json +2 -2
@@ -1,11 +1,12 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
- import { readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { readdir, readFile, realpath, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
- import { buildDockerRunArgs } from "./args.js";
6
+ import { buildDockerEnvArgs, buildDockerRunArgs } from "./args.js";
7
7
  import { buildContextArgs, detectContext } from "./context.js";
8
8
  import { detectEngine } from "./engine.js";
9
+ import { createDockerEnvFile } from "./env-file.js";
9
10
  import { createHostRunner } from "../host/host-runner.js";
10
11
  import { downloadWorkspace as downloadTransferredWorkspace, uploadWorkspace as uploadTransferredWorkspace } from "../workspace-transfer.js";
11
12
  const containerCommand = ["sh", "-c", "while :; do sleep 3600; done"];
@@ -103,25 +104,33 @@ function createDockerEnv(input) {
103
104
  return downloadTransferredWorkspace(workspaceTransferEnv, opts);
104
105
  },
105
106
  exec(spec) {
106
- return input.runner.exec({
107
- command: input.engine,
108
- args: [
109
- ...buildContextArgs(input.engine, input.context),
110
- "exec",
111
- ...(spec.stdin === "pipe" || spec.stdin === "inherit" ? ["-i"] : []),
112
- ...(spec.tty === true ? ["-t"] : []),
113
- ...(spec.cwd !== undefined ? ["-w", spec.cwd] : []),
114
- ...buildEnvArgs(spec.env),
115
- containerRef,
116
- spec.command,
117
- ...(spec.args ?? [])
118
- ],
119
- stdin: spec.stdin,
120
- stdout: spec.stdout,
121
- stderr: spec.stderr,
122
- tty: spec.tty,
123
- signal: spec.signal
124
- });
107
+ const envFile = createDockerEnvFile(spec.env);
108
+ try {
109
+ return cleanUpEnvFileAfterRun(input.runner.exec({
110
+ command: input.engine,
111
+ args: [
112
+ ...buildContextArgs(input.engine, input.context),
113
+ "exec",
114
+ ...(spec.stdin === "pipe" || spec.stdin === "inherit" ? ["-i"] : []),
115
+ ...(spec.tty === true ? ["-t"] : []),
116
+ ...(spec.cwd !== undefined ? ["-w", spec.cwd] : []),
117
+ ...buildDockerEnvArgs({ env: spec.env, envFilePath: envFile?.path }),
118
+ containerRef,
119
+ spec.command,
120
+ ...(spec.args ?? [])
121
+ ],
122
+ stdin: spec.stdin,
123
+ stdout: spec.stdout,
124
+ stderr: spec.stderr,
125
+ tty: spec.tty,
126
+ signal: spec.signal,
127
+ killProcessGroup: spec.killProcessGroup
128
+ }), envFile?.cleanup);
129
+ }
130
+ catch (error) {
131
+ envFile?.cleanup();
132
+ throw error;
133
+ }
125
134
  },
126
135
  async detach() {
127
136
  return createContainerJob(containerRef, input.runner, input.engine, input.context, detachedJobContext);
@@ -136,7 +145,8 @@ function createDockerEnv(input) {
136
145
  stdin: "inherit",
137
146
  stdout: "inherit",
138
147
  stderr: "inherit",
139
- tty: true
148
+ tty: true,
149
+ signal: shellSpec?.signal
140
150
  });
141
151
  },
142
152
  async close() {
@@ -173,8 +183,7 @@ export async function buildDockerRuntimeTemplate(input) {
173
183
  const runner = input.runner ?? createHostRunner();
174
184
  const engine = input.runtime.engine ?? detectEngine();
175
185
  const context = detectContext();
176
- const dockerfilePath = path.resolve(input.cwd, input.runtime.dockerfile ?? path.join(".poe-code", "Dockerfile"));
177
- const buildContext = path.resolve(input.cwd, input.runtime.build_context ?? ".");
186
+ const { dockerfilePath, buildContext } = await resolveRuntimeBuildPaths(input.cwd, input.runtime);
178
187
  const dockerfileBytes = await readFile(dockerfilePath);
179
188
  const buildContextFiles = await readBuildContextFiles(buildContext);
180
189
  const hash = hashDockerTemplate(dockerfileBytes, buildContextFiles, input.runtime.build_args ?? {}, engine);
@@ -211,6 +220,28 @@ export async function buildDockerRuntimeTemplate(input) {
211
220
  cached: false
212
221
  };
213
222
  }
223
+ async function resolveRuntimeBuildPaths(cwd, runtime) {
224
+ const dockerfilePath = path.resolve(cwd, runtime.dockerfile ?? path.join(".poe-code", "Dockerfile"));
225
+ const buildContext = path.resolve(cwd, runtime.build_context ?? ".");
226
+ const canonicalCwd = await realpath(cwd);
227
+ const canonicalDockerfilePath = await realpath(dockerfilePath);
228
+ const canonicalBuildContext = await realpath(buildContext);
229
+ assertRuntimePathInsideCwd(canonicalCwd, canonicalDockerfilePath, "runtime.dockerfile");
230
+ assertRuntimePathInsideCwd(canonicalCwd, canonicalBuildContext, "runtime.build_context");
231
+ return {
232
+ dockerfilePath: canonicalDockerfilePath,
233
+ buildContext: canonicalBuildContext
234
+ };
235
+ }
236
+ function assertRuntimePathInsideCwd(cwd, targetPath, fieldName) {
237
+ if (!isPathInsideOrEqual(cwd, targetPath)) {
238
+ throw new Error(`${fieldName} must remain inside runtime cwd ${cwd}.`);
239
+ }
240
+ }
241
+ function isPathInsideOrEqual(rootPath, targetPath) {
242
+ const relativePath = path.relative(rootPath, targetPath);
243
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
244
+ }
214
245
  function hashDockerTemplate(dockerfileBytes, buildContextFiles, buildArgs, engine) {
215
246
  const hash = createHash("sha256");
216
247
  hash.update(dockerfileBytes);
@@ -306,9 +337,49 @@ async function runAndRead(runner, spec) {
306
337
  }
307
338
  return output;
308
339
  }
340
+ async function runAndReadBytes(runner, spec) {
341
+ const handle = runner.exec(spec);
342
+ const stdout = readStreamBytes(handle.stdout);
343
+ const stderr = readStream(handle.stderr);
344
+ const result = await handle.result;
345
+ const output = await stdout;
346
+ if (result.exitCode !== 0) {
347
+ const errorOutput = await stderr;
348
+ throw new Error(`Command failed with exit code ${result.exitCode}: ${spec.command} ${(spec.args ?? []).join(" ")}${errorOutput ? `\n${errorOutput}` : ""}`);
349
+ }
350
+ return output;
351
+ }
309
352
  async function runOrThrow(runner, spec) {
310
353
  await runAndRead(runner, spec);
311
354
  }
355
+ function cleanUpEnvFileAfterRun(handle, cleanup) {
356
+ if (cleanup === undefined) {
357
+ return handle;
358
+ }
359
+ let cleanedUp = false;
360
+ const cleanupOnce = () => {
361
+ if (cleanedUp) {
362
+ return;
363
+ }
364
+ cleanedUp = true;
365
+ try {
366
+ cleanup();
367
+ }
368
+ catch {
369
+ // Cleanup is best effort; preserve the command result.
370
+ }
371
+ };
372
+ return {
373
+ pid: handle.pid,
374
+ stdin: handle.stdin,
375
+ stdout: handle.stdout,
376
+ stderr: handle.stderr,
377
+ result: handle.result.finally(cleanupOnce),
378
+ kill(signal) {
379
+ handle.kill(signal);
380
+ }
381
+ };
382
+ }
312
383
  async function readStream(stream) {
313
384
  if (stream === null) {
314
385
  return "";
@@ -320,15 +391,19 @@ async function readStream(stream) {
320
391
  }
321
392
  return chunks.join("");
322
393
  }
394
+ async function readStreamBytes(stream) {
395
+ if (stream === null) {
396
+ return Buffer.alloc(0);
397
+ }
398
+ const chunks = [];
399
+ for await (const chunk of stream) {
400
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk));
401
+ }
402
+ return Buffer.concat(chunks);
403
+ }
323
404
  function sortedBuildArgs(buildArgs) {
324
405
  return Object.entries(buildArgs).sort(([left], [right]) => left.localeCompare(right));
325
406
  }
326
- function buildEnvArgs(env) {
327
- if (env === undefined) {
328
- return [];
329
- }
330
- return Object.entries(env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
331
- }
332
407
  function createContainerName() {
333
408
  return `poe-env-${randomBytes(6).toString("hex")}`;
334
409
  }
@@ -383,10 +458,25 @@ function createContainerWorkspaceFileSystem(input) {
383
458
  await execShell(`mkdir -p ${shellQuote(targetPath)}`);
384
459
  },
385
460
  async readdir(targetPath) {
386
- const output = await execShell(`for item in ${shellQuote(targetPath)}/* ${shellQuote(targetPath)}/.[!.]* ${shellQuote(targetPath)}/..?*; do [ -e "$item" ] || continue; if [ -d "$item" ]; then kind=d; size=0; else kind=f; size=$(wc -c < "$item"); fi; printf '%s\\t%s\\t%s\\n' "\${item##*/}" "$kind" "$size"; done`);
461
+ const quotedTargetPath = shellQuote(targetPath);
462
+ const output = await execShell([
463
+ `for item in ${quotedTargetPath}/* ${quotedTargetPath}/.[!.]* ${quotedTargetPath}/..?*; do`,
464
+ `[ -e "$item" ] || [ -L "$item" ] || continue;`,
465
+ `if [ -L "$item" ]; then kind=l; size=0;`,
466
+ `elif [ -d "$item" ]; then kind=d; size=0;`,
467
+ `elif [ -f "$item" ]; then kind=f; size=$(wc -c < "$item");`,
468
+ `else continue; fi;`,
469
+ `printf '%s\\t%s\\t%s\\n' "\${item##*/}" "$kind" "$size";`,
470
+ `done`
471
+ ].join(" "));
387
472
  return output.split("\n").filter(Boolean).map((line) => {
388
473
  const [name = "", kind = "f"] = line.split("\t");
389
- return { name, isFile: () => kind === "f", isDirectory: () => kind === "d" };
474
+ return {
475
+ name,
476
+ isFile: () => kind === "f",
477
+ isDirectory: () => kind === "d",
478
+ isSymbolicLink: () => kind === "l"
479
+ };
390
480
  });
391
481
  },
392
482
  readFile: readFileFromContainer,
@@ -459,8 +549,10 @@ function createContainerJob(containerId, runner, engine, context, detachedJobCon
459
549
  ? ""
460
550
  : ` && test $(stat -c %Y ${logFile} 2>/dev/null || stat -f %m ${logFile}) -ge ${Math.ceil(opts.since.getTime() / 1000)}`;
461
551
  let byteOffset = opts?.sinceByte ?? 0;
552
+ let pendingBytes = Buffer.alloc(0);
553
+ let pendingByteOffset = byteOffset;
462
554
  while (true) {
463
- const stdout = await runAndRead(runner, {
555
+ const stdout = await runAndReadBytes(runner, {
464
556
  command: engine,
465
557
  args: [
466
558
  ...buildContextArgs(engine, context),
@@ -473,9 +565,18 @@ function createContainerJob(containerId, runner, engine, context, detachedJobCon
473
565
  stdout: "pipe",
474
566
  stderr: "pipe"
475
567
  });
476
- if (stdout.length > 0) {
477
- yield { byteOffset, data: stdout };
478
- byteOffset += Buffer.byteLength(stdout);
568
+ if (stdout.byteLength > 0) {
569
+ const combined = pendingBytes.byteLength === 0
570
+ ? stdout
571
+ : Buffer.concat([pendingBytes, stdout]);
572
+ const completeLength = completeUtf8PrefixLength(combined);
573
+ byteOffset += stdout.byteLength;
574
+ pendingBytes = combined.subarray(completeLength);
575
+ const data = combined.subarray(0, completeLength).toString("utf8");
576
+ if (data.length > 0) {
577
+ yield { byteOffset: pendingByteOffset, data };
578
+ pendingByteOffset += completeLength;
579
+ }
479
580
  }
480
581
  if (opts?.follow !== true || (await this.status()) !== "running") {
481
582
  return;
@@ -517,6 +618,39 @@ function createContainerJob(containerId, runner, engine, context, detachedJobCon
517
618
  }
518
619
  };
519
620
  }
621
+ function completeUtf8PrefixLength(contents) {
622
+ if (contents.length === 0) {
623
+ return 0;
624
+ }
625
+ let leadIndex = contents.length - 1;
626
+ while (leadIndex >= 0 && isUtf8ContinuationByte(contents[leadIndex])) {
627
+ leadIndex -= 1;
628
+ }
629
+ if (leadIndex < 0) {
630
+ return contents.length;
631
+ }
632
+ const expectedLength = utf8SequenceLength(contents[leadIndex]);
633
+ if (expectedLength === 0) {
634
+ return contents.length;
635
+ }
636
+ const availableLength = contents.length - leadIndex;
637
+ return availableLength < expectedLength ? leadIndex : contents.length;
638
+ }
639
+ function isUtf8ContinuationByte(byte) {
640
+ return byte >= 0x80 && byte <= 0xbf;
641
+ }
642
+ function utf8SequenceLength(byte) {
643
+ if (byte >= 0xc2 && byte <= 0xdf) {
644
+ return 2;
645
+ }
646
+ if (byte >= 0xe0 && byte <= 0xef) {
647
+ return 3;
648
+ }
649
+ if (byte >= 0xf0 && byte <= 0xf4) {
650
+ return 4;
651
+ }
652
+ return 0;
653
+ }
520
654
  async function readDetachedExitCode(containerId, jobId, runner, engine, context) {
521
655
  const exitFile = shellQuote(`/tmp/poe-jobs/${jobId}.exit`);
522
656
  const handle = runner.exec({
@@ -3,6 +3,9 @@ import { randomBytes } from "node:crypto";
3
3
  import { buildDockerRunArgs } from "./args.js";
4
4
  import { buildContextArgs, detectContext } from "./context.js";
5
5
  import { detectEngine } from "./engine.js";
6
+ import { createDockerEnvFile } from "./env-file.js";
7
+ const DOCKER_ABORT_GRACE_MS = 10_000;
8
+ const DOCKER_ABORT_FORCE_GRACE_MS = 5_000;
6
9
  export function createDockerRunner(options) {
7
10
  const engine = options.engine ?? detectEngine();
8
11
  const context = options.context ?? detectContext();
@@ -27,6 +30,7 @@ export function createDockerRunner(options) {
27
30
  stderrMode === "inherit" &&
28
31
  spec.tty === true;
29
32
  const containerName = buildContainerName(options.containerName ?? spec.command);
33
+ const envFile = createDockerEnvFile(spec.env);
30
34
  const runArgs = buildDockerRunArgs({
31
35
  engine,
32
36
  context,
@@ -35,6 +39,7 @@ export function createDockerRunner(options) {
35
39
  args: spec.args ?? [],
36
40
  cwd: spec.cwd,
37
41
  env: spec.env,
42
+ envFilePath: envFile?.path,
38
43
  mounts: options.mounts ?? [],
39
44
  ports: options.ports ?? [],
40
45
  network: options.network,
@@ -46,25 +51,55 @@ export function createDockerRunner(options) {
46
51
  extraArgs: options.extraArgs ?? []
47
52
  });
48
53
  const [command, ...args] = runArgs;
49
- const child = childProcess.spawn(command, args, {
50
- stdio: interactiveMode ? "inherit" : [stdinMode, stdoutMode, stderrMode]
51
- });
54
+ let child;
55
+ try {
56
+ child = childProcess.spawn(command, args, {
57
+ stdio: interactiveMode ? "inherit" : [stdinMode, stdoutMode, stderrMode]
58
+ });
59
+ }
60
+ catch (error) {
61
+ envFile?.cleanup();
62
+ throw error;
63
+ }
52
64
  let isResultSettled = false;
65
+ let exitCodeOverride = null;
53
66
  let resolveResult = null;
67
+ let abortEscalationTimers = [];
54
68
  const result = new Promise((resolve) => {
55
69
  resolveResult = resolve;
56
70
  });
71
+ const clearAbortEscalation = () => {
72
+ for (const timer of abortEscalationTimers) {
73
+ clearTimeout(timer);
74
+ }
75
+ abortEscalationTimers = [];
76
+ };
57
77
  const settleResult = (exitCode) => {
58
78
  if (isResultSettled) {
59
79
  return;
60
80
  }
61
81
  isResultSettled = true;
62
82
  cleanupAbort();
63
- resolveResult?.({ exitCode });
83
+ clearAbortEscalation();
84
+ envFile?.cleanup();
85
+ resolveResult?.({ exitCode: exitCodeOverride ?? exitCode });
86
+ };
87
+ const scheduleAbortEscalation = () => {
88
+ const terminateTimer = setTimeout(() => {
89
+ killHostDockerChild(child, "SIGTERM");
90
+ const forceTimer = setTimeout(() => {
91
+ killHostDockerChild(child, "SIGKILL");
92
+ }, DOCKER_ABORT_FORCE_GRACE_MS);
93
+ unrefTimer(forceTimer);
94
+ abortEscalationTimers.push(forceTimer);
95
+ }, DOCKER_ABORT_GRACE_MS);
96
+ unrefTimer(terminateTimer);
97
+ abortEscalationTimers.push(terminateTimer);
64
98
  };
65
99
  const cleanupAbort = bindAbortSignal(spec.signal, () => {
66
- settleResult(1);
100
+ exitCodeOverride = 1;
67
101
  spawnControlCommand(engine, context, ["stop", containerName]);
102
+ scheduleAbortEscalation();
68
103
  });
69
104
  child.once("error", () => {
70
105
  settleResult(1);
@@ -93,6 +128,14 @@ export function createDockerRunner(options) {
93
128
  }
94
129
  };
95
130
  }
131
+ function killHostDockerChild(child, signal) {
132
+ try {
133
+ child.kill(signal);
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ }
96
139
  function buildContainerName(name) {
97
140
  const suffix = randomBytes(3).toString("hex").slice(0, 6);
98
141
  const sanitizedName = sanitizeContainerName(name);
@@ -123,10 +166,16 @@ function isContainerNameCharacter(char) {
123
166
  return char === "." || char === "_" || char === "-";
124
167
  }
125
168
  function spawnControlCommand(engine, context, args) {
126
- const child = childProcess.spawn(engine, [...buildContextArgs(engine, context), ...args], {
127
- stdio: "ignore"
128
- });
129
- child.once("error", () => undefined);
169
+ try {
170
+ const child = childProcess.spawn(engine, [...buildContextArgs(engine, context), ...args], {
171
+ stdio: "ignore"
172
+ });
173
+ child.once("error", () => undefined);
174
+ child.unref();
175
+ }
176
+ catch {
177
+ return;
178
+ }
130
179
  }
131
180
  function bindAbortSignal(signal, onAbort) {
132
181
  if (signal === undefined) {
@@ -141,3 +190,11 @@ function bindAbortSignal(signal, onAbort) {
141
190
  signal.removeEventListener("abort", onAbort);
142
191
  };
143
192
  }
193
+ function unrefTimer(timer) {
194
+ if (typeof timer === "object" &&
195
+ timer !== null &&
196
+ "unref" in timer &&
197
+ typeof timer.unref === "function") {
198
+ timer.unref();
199
+ }
200
+ }
@@ -0,0 +1,6 @@
1
+ export interface DockerEnvFile {
2
+ path: string;
3
+ cleanup(): void;
4
+ }
5
+ export declare function createDockerEnvFile(env: Record<string, string> | undefined): DockerEnvFile | null;
6
+ export declare function serializeDockerEnvFile(entries: Array<[string, string]>): string;
@@ -0,0 +1,49 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ export function createDockerEnvFile(env) {
5
+ const entries = Object.entries(env ?? {});
6
+ if (entries.length === 0) {
7
+ return null;
8
+ }
9
+ const directory = mkdtempSync(path.join(tmpdir(), "poe-docker-env-"));
10
+ const filePath = path.join(directory, "env");
11
+ let active = true;
12
+ try {
13
+ writeFileSync(filePath, serializeDockerEnvFile(entries), {
14
+ encoding: "utf8",
15
+ flag: "wx",
16
+ mode: 0o600
17
+ });
18
+ }
19
+ catch (error) {
20
+ rmSync(directory, { recursive: true, force: true });
21
+ throw error;
22
+ }
23
+ return {
24
+ path: filePath,
25
+ cleanup() {
26
+ if (!active) {
27
+ return;
28
+ }
29
+ active = false;
30
+ rmSync(directory, { recursive: true, force: true });
31
+ }
32
+ };
33
+ }
34
+ export function serializeDockerEnvFile(entries) {
35
+ return (entries.map(([key, value]) => `${formatDockerEnvKey(key)}=${formatDockerEnvValue(value)}`).join("\n") +
36
+ "\n");
37
+ }
38
+ function formatDockerEnvKey(key) {
39
+ if (key.length === 0 || key.includes("=") || key.includes("\n") || key.includes("\r")) {
40
+ throw new Error(`Invalid Docker environment variable name: ${JSON.stringify(key)}`);
41
+ }
42
+ return key;
43
+ }
44
+ function formatDockerEnvValue(value) {
45
+ if (value.includes("\n") || value.includes("\r")) {
46
+ throw new Error("Docker env-file values cannot contain newline characters.");
47
+ }
48
+ return value;
49
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn as spawnChildProcess } from "node:child_process";
2
2
  export function createHostRunner(options = {}) {
3
- const detached = options.detached === true;
3
+ const detachedByDefault = options.detached === true;
4
4
  return {
5
5
  name: "host",
6
6
  exec(spec) {
@@ -17,6 +17,7 @@ export function createHostRunner(options = {}) {
17
17
  const stdinMode = spec.stdin ?? "ignore";
18
18
  const stdoutMode = spec.stdout ?? "pipe";
19
19
  const stderrMode = spec.stderr ?? "pipe";
20
+ const killProcessGroup = detachedByDefault || spec.killProcessGroup === true;
20
21
  const stdio = stdinMode === "inherit" && stdoutMode === "inherit" && stderrMode === "inherit"
21
22
  ? "inherit"
22
23
  : [stdinMode, stdoutMode, stderrMode];
@@ -24,13 +25,13 @@ export function createHostRunner(options = {}) {
24
25
  cwd: spec.cwd,
25
26
  env: spec.env,
26
27
  stdio,
27
- ...(detached ? { detached: true } : {})
28
+ ...(killProcessGroup ? { detached: true } : {})
28
29
  });
29
- if (detached) {
30
+ if (killProcessGroup) {
30
31
  child.unref();
31
32
  }
32
33
  const kill = (signal) => {
33
- if (detached && process.platform !== "win32" && child.pid !== undefined) {
34
+ if (killProcessGroup && process.platform !== "win32" && child.pid !== undefined) {
34
35
  process.kill(-child.pid, signal);
35
36
  return;
36
37
  }
@@ -20,6 +20,8 @@ export interface RunSpec {
20
20
  stderr?: "pipe" | "inherit";
21
21
  tty?: boolean;
22
22
  signal?: AbortSignal;
23
+ /** Start in a separate process group so kill() can signal the full group where supported. */
24
+ killProcessGroup?: boolean;
23
25
  }
24
26
  export interface Runner {
25
27
  exec(spec: RunSpec): RunHandle;
@@ -163,6 +165,7 @@ export interface DockerRunArgs {
163
165
  args: string[];
164
166
  cwd?: string;
165
167
  env?: Record<string, string>;
168
+ envFilePath?: string;
166
169
  mounts: DockerMount[];
167
170
  ports: DockerPortMapping[];
168
171
  network?: string;
@@ -4,6 +4,7 @@ export interface WorkspaceTransferDirent {
4
4
  name: string;
5
5
  isFile(): boolean;
6
6
  isDirectory(): boolean;
7
+ isSymbolicLink?(): boolean;
7
8
  }
8
9
  export interface WorkspaceTransferStats {
9
10
  isFile(): boolean;
@@ -20,7 +21,10 @@ export interface WorkspaceTransferFileSystem {
20
21
  }): Promise<WorkspaceTransferDirent[]>;
21
22
  readFile(path: string): Promise<Buffer>;
22
23
  readFile(path: string, encoding: BufferEncoding): Promise<string>;
23
- writeFile(path: string, data: string | Buffer): Promise<void>;
24
+ writeFile(path: string, data: string | Buffer, options?: {
25
+ flag?: string;
26
+ mode?: number;
27
+ }): Promise<void>;
24
28
  stat(path: string): Promise<WorkspaceTransferStats>;
25
29
  lstat?(path: string): Promise<WorkspaceTransferStats>;
26
30
  rename?(oldPath: string, newPath: string): Promise<void>;
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { promises as nodeFs } from "node:fs";
3
3
  import path from "node:path";
4
4
  const uploadState = new WeakMap();
@@ -82,7 +82,7 @@ export async function downloadWorkspace(env, opts) {
82
82
  const remoteFs = env.remoteFs ?? localFs;
83
83
  const workspaceDir = env.workspaceDir ?? "/workspace";
84
84
  const state = uploadState.get(env) ?? new Map();
85
- const remoteFiles = await listFilesIfExists(remoteFs, workspaceDir);
85
+ const remoteFiles = await listFilesIfExists(remoteFs, workspaceDir, { rejectSymlinks: true });
86
86
  const remotePaths = new Set(remoteFiles.map((file) => file.path));
87
87
  const conflicts = [];
88
88
  let files = 0;
@@ -96,7 +96,7 @@ export async function downloadWorkspace(env, opts) {
96
96
  conflicts.push({ path: remoteFile.path, reason: "local_modified" });
97
97
  continue;
98
98
  }
99
- await writeFileAtomically(localFs, localPath, remoteContent, ".download-tmp");
99
+ await writeFileAtomically(localFs, env.cwd, localPath, remoteContent, ".download-tmp");
100
100
  state.set(remoteFile.path, {
101
101
  hash: hashBuffer(remoteContent),
102
102
  uploaded: true
@@ -122,9 +122,9 @@ export async function downloadWorkspace(env, opts) {
122
122
  }
123
123
  return { files, bytes, conflicts };
124
124
  }
125
- async function listFilesIfExists(fs, root) {
125
+ async function listFilesIfExists(fs, root, options = {}) {
126
126
  try {
127
- return await listFiles(fs, root);
127
+ return await listFiles(fs, root, options);
128
128
  }
129
129
  catch (error) {
130
130
  if (isNotFoundError(error)) {
@@ -133,12 +133,15 @@ async function listFilesIfExists(fs, root) {
133
133
  throw error;
134
134
  }
135
135
  }
136
- async function listFiles(fs, root) {
136
+ async function listFiles(fs, root, options = {}) {
137
137
  const result = [];
138
138
  async function visit(dir) {
139
139
  const dirents = await fs.readdir(dir, { withFileTypes: true });
140
140
  for (const dirent of dirents.sort((left, right) => left.name.localeCompare(right.name))) {
141
141
  const absolutePath = path.join(dir, dirent.name);
142
+ if (options.rejectSymlinks === true && dirent.isSymbolicLink?.() === true) {
143
+ throw new Error("Workspace download must not follow symbolic links.");
144
+ }
142
145
  if (dirent.isDirectory()) {
143
146
  await visit(absolutePath);
144
147
  continue;
@@ -347,15 +350,28 @@ async function renamePath(fs, sourcePath, destinationPath) {
347
350
  }
348
351
  await fs.rename(sourcePath, destinationPath);
349
352
  }
350
- async function writeFileAtomically(fs, destinationPath, data, temporarySuffix) {
351
- const temporaryPath = `${destinationPath}${temporarySuffix}`;
353
+ async function writeFileAtomically(fs, workspacePath, destinationPath, data, temporarySuffix) {
354
+ const temporaryPath = `${destinationPath}.${randomUUID()}${temporarySuffix}`;
355
+ let temporaryCreated = false;
352
356
  await fs.mkdir(path.dirname(destinationPath), { recursive: true });
353
357
  try {
354
- await fs.writeFile(temporaryPath, data);
358
+ await assertSafeLocalDownloadPath(fs, workspacePath, temporaryPath);
359
+ try {
360
+ await fs.writeFile(temporaryPath, data, { flag: "wx", mode: 0o600 });
361
+ temporaryCreated = true;
362
+ }
363
+ catch (error) {
364
+ if (!isAlreadyExistsError(error)) {
365
+ await removeFile(fs, temporaryPath).catch(() => undefined);
366
+ }
367
+ throw error;
368
+ }
355
369
  await renamePath(fs, temporaryPath, destinationPath);
356
370
  }
357
371
  catch (error) {
358
- await removeFile(fs, temporaryPath).catch(() => undefined);
372
+ if (temporaryCreated) {
373
+ await removeFile(fs, temporaryPath).catch(() => undefined);
374
+ }
359
375
  throw error;
360
376
  }
361
377
  }
@@ -482,3 +498,6 @@ function stripSlashes(value) {
482
498
  function isNotFoundError(error) {
483
499
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
484
500
  }
501
+ function isAlreadyExistsError(error) {
502
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
503
+ }
@@ -33,6 +33,7 @@ export interface EncryptedFileStoreInput {
33
33
  export declare class EncryptedFileStore implements SecretStore {
34
34
  private readonly fs;
35
35
  private readonly filePath;
36
+ private readonly symbolicLinkCheckStartPath;
36
37
  private readonly salt;
37
38
  private readonly getMachineIdentity;
38
39
  private readonly getRandomBytes;