toolcraft 0.0.24 → 0.0.25

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.
@@ -1,2 +1,3 @@
1
1
  import type { DockerRunArgs } from "../types.js";
2
+ export declare function buildDockerEnvArgs(input: Pick<DockerRunArgs, "env" | "envFilePath">): string[];
2
3
  export declare function buildDockerRunArgs(input: DockerRunArgs): string[];
@@ -1,4 +1,14 @@
1
1
  import path from "node:path";
2
+ export function buildDockerEnvArgs(input) {
3
+ const keys = Object.keys(input.env ?? {});
4
+ if (keys.length === 0) {
5
+ return [];
6
+ }
7
+ if (input.envFilePath !== undefined) {
8
+ return ["--env-file", input.envFilePath];
9
+ }
10
+ return keys.flatMap((key) => ["-e", key]);
11
+ }
2
12
  export function buildDockerRunArgs(input) {
3
13
  const args = [input.engine];
4
14
  if (input.engine === "docker" && input.context) {
@@ -21,9 +31,7 @@ export function buildDockerRunArgs(input) {
21
31
  if (input.cwd !== undefined) {
22
32
  args.push("-w", input.cwd);
23
33
  }
24
- for (const [key, value] of Object.entries(input.env ?? {})) {
25
- args.push("-e", `${key}=${value}`);
26
- }
34
+ args.push(...buildDockerEnvArgs(input));
27
35
  for (const mount of input.mounts) {
28
36
  const volume = `${path.resolve(mount.source)}:${mount.target}${mount.readonly ? ":ro" : ""}`;
29
37
  args.push("-v", volume);
@@ -3,9 +3,10 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { readdir, readFile, 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() {
@@ -306,9 +316,49 @@ async function runAndRead(runner, spec) {
306
316
  }
307
317
  return output;
308
318
  }
319
+ async function runAndReadBytes(runner, spec) {
320
+ const handle = runner.exec(spec);
321
+ const stdout = readStreamBytes(handle.stdout);
322
+ const stderr = readStream(handle.stderr);
323
+ const result = await handle.result;
324
+ const output = await stdout;
325
+ if (result.exitCode !== 0) {
326
+ const errorOutput = await stderr;
327
+ throw new Error(`Command failed with exit code ${result.exitCode}: ${spec.command} ${(spec.args ?? []).join(" ")}${errorOutput ? `\n${errorOutput}` : ""}`);
328
+ }
329
+ return output;
330
+ }
309
331
  async function runOrThrow(runner, spec) {
310
332
  await runAndRead(runner, spec);
311
333
  }
334
+ function cleanUpEnvFileAfterRun(handle, cleanup) {
335
+ if (cleanup === undefined) {
336
+ return handle;
337
+ }
338
+ let cleanedUp = false;
339
+ const cleanupOnce = () => {
340
+ if (cleanedUp) {
341
+ return;
342
+ }
343
+ cleanedUp = true;
344
+ try {
345
+ cleanup();
346
+ }
347
+ catch {
348
+ // Cleanup is best effort; preserve the command result.
349
+ }
350
+ };
351
+ return {
352
+ pid: handle.pid,
353
+ stdin: handle.stdin,
354
+ stdout: handle.stdout,
355
+ stderr: handle.stderr,
356
+ result: handle.result.finally(cleanupOnce),
357
+ kill(signal) {
358
+ handle.kill(signal);
359
+ }
360
+ };
361
+ }
312
362
  async function readStream(stream) {
313
363
  if (stream === null) {
314
364
  return "";
@@ -320,15 +370,19 @@ async function readStream(stream) {
320
370
  }
321
371
  return chunks.join("");
322
372
  }
373
+ async function readStreamBytes(stream) {
374
+ if (stream === null) {
375
+ return Buffer.alloc(0);
376
+ }
377
+ const chunks = [];
378
+ for await (const chunk of stream) {
379
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk));
380
+ }
381
+ return Buffer.concat(chunks);
382
+ }
323
383
  function sortedBuildArgs(buildArgs) {
324
384
  return Object.entries(buildArgs).sort(([left], [right]) => left.localeCompare(right));
325
385
  }
326
- function buildEnvArgs(env) {
327
- if (env === undefined) {
328
- return [];
329
- }
330
- return Object.entries(env).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
331
- }
332
386
  function createContainerName() {
333
387
  return `poe-env-${randomBytes(6).toString("hex")}`;
334
388
  }
@@ -383,10 +437,25 @@ function createContainerWorkspaceFileSystem(input) {
383
437
  await execShell(`mkdir -p ${shellQuote(targetPath)}`);
384
438
  },
385
439
  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`);
440
+ const quotedTargetPath = shellQuote(targetPath);
441
+ const output = await execShell([
442
+ `for item in ${quotedTargetPath}/* ${quotedTargetPath}/.[!.]* ${quotedTargetPath}/..?*; do`,
443
+ `[ -e "$item" ] || [ -L "$item" ] || continue;`,
444
+ `if [ -L "$item" ]; then kind=l; size=0;`,
445
+ `elif [ -d "$item" ]; then kind=d; size=0;`,
446
+ `elif [ -f "$item" ]; then kind=f; size=$(wc -c < "$item");`,
447
+ `else continue; fi;`,
448
+ `printf '%s\\t%s\\t%s\\n' "\${item##*/}" "$kind" "$size";`,
449
+ `done`
450
+ ].join(" "));
387
451
  return output.split("\n").filter(Boolean).map((line) => {
388
452
  const [name = "", kind = "f"] = line.split("\t");
389
- return { name, isFile: () => kind === "f", isDirectory: () => kind === "d" };
453
+ return {
454
+ name,
455
+ isFile: () => kind === "f",
456
+ isDirectory: () => kind === "d",
457
+ isSymbolicLink: () => kind === "l"
458
+ };
390
459
  });
391
460
  },
392
461
  readFile: readFileFromContainer,
@@ -459,8 +528,10 @@ function createContainerJob(containerId, runner, engine, context, detachedJobCon
459
528
  ? ""
460
529
  : ` && test $(stat -c %Y ${logFile} 2>/dev/null || stat -f %m ${logFile}) -ge ${Math.ceil(opts.since.getTime() / 1000)}`;
461
530
  let byteOffset = opts?.sinceByte ?? 0;
531
+ let pendingBytes = Buffer.alloc(0);
532
+ let pendingByteOffset = byteOffset;
462
533
  while (true) {
463
- const stdout = await runAndRead(runner, {
534
+ const stdout = await runAndReadBytes(runner, {
464
535
  command: engine,
465
536
  args: [
466
537
  ...buildContextArgs(engine, context),
@@ -473,9 +544,18 @@ function createContainerJob(containerId, runner, engine, context, detachedJobCon
473
544
  stdout: "pipe",
474
545
  stderr: "pipe"
475
546
  });
476
- if (stdout.length > 0) {
477
- yield { byteOffset, data: stdout };
478
- byteOffset += Buffer.byteLength(stdout);
547
+ if (stdout.byteLength > 0) {
548
+ const combined = pendingBytes.byteLength === 0
549
+ ? stdout
550
+ : Buffer.concat([pendingBytes, stdout]);
551
+ const completeLength = completeUtf8PrefixLength(combined);
552
+ byteOffset += stdout.byteLength;
553
+ pendingBytes = combined.subarray(completeLength);
554
+ const data = combined.subarray(0, completeLength).toString("utf8");
555
+ if (data.length > 0) {
556
+ yield { byteOffset: pendingByteOffset, data };
557
+ pendingByteOffset += completeLength;
558
+ }
479
559
  }
480
560
  if (opts?.follow !== true || (await this.status()) !== "running") {
481
561
  return;
@@ -517,6 +597,39 @@ function createContainerJob(containerId, runner, engine, context, detachedJobCon
517
597
  }
518
598
  };
519
599
  }
600
+ function completeUtf8PrefixLength(contents) {
601
+ if (contents.length === 0) {
602
+ return 0;
603
+ }
604
+ let leadIndex = contents.length - 1;
605
+ while (leadIndex >= 0 && isUtf8ContinuationByte(contents[leadIndex])) {
606
+ leadIndex -= 1;
607
+ }
608
+ if (leadIndex < 0) {
609
+ return contents.length;
610
+ }
611
+ const expectedLength = utf8SequenceLength(contents[leadIndex]);
612
+ if (expectedLength === 0) {
613
+ return contents.length;
614
+ }
615
+ const availableLength = contents.length - leadIndex;
616
+ return availableLength < expectedLength ? leadIndex : contents.length;
617
+ }
618
+ function isUtf8ContinuationByte(byte) {
619
+ return byte >= 0x80 && byte <= 0xbf;
620
+ }
621
+ function utf8SequenceLength(byte) {
622
+ if (byte >= 0xc2 && byte <= 0xdf) {
623
+ return 2;
624
+ }
625
+ if (byte >= 0xe0 && byte <= 0xef) {
626
+ return 3;
627
+ }
628
+ if (byte >= 0xf0 && byte <= 0xf4) {
629
+ return 4;
630
+ }
631
+ return 0;
632
+ }
520
633
  async function readDetachedExitCode(containerId, jobId, runner, engine, context) {
521
634
  const exitFile = shellQuote(`/tmp/poe-jobs/${jobId}.exit`);
522
635
  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,15 @@ 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
+ }
175
+ catch {
176
+ return;
177
+ }
130
178
  }
131
179
  function bindAbortSignal(signal, onAbort) {
132
180
  if (signal === undefined) {
@@ -141,3 +189,11 @@ function bindAbortSignal(signal, onAbort) {
141
189
  signal.removeEventListener("abort", onAbort);
142
190
  };
143
191
  }
192
+ function unrefTimer(timer) {
193
+ if (typeof timer === "object" &&
194
+ timer !== null &&
195
+ "unref" in timer &&
196
+ typeof timer.unref === "function") {
197
+ timer.unref();
198
+ }
199
+ }
@@ -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;
@@ -13,6 +13,7 @@ let temporaryFileSequence = 0;
13
13
  export class EncryptedFileStore {
14
14
  fs;
15
15
  filePath;
16
+ symbolicLinkCheckStartPath;
16
17
  salt;
17
18
  getMachineIdentity;
18
19
  getRandomBytes;
@@ -20,7 +21,16 @@ export class EncryptedFileStore {
20
21
  constructor(input) {
21
22
  this.fs = input.fs ?? fs;
22
23
  this.salt = input.salt;
23
- this.filePath = input.filePath ?? path.join((input.getHomeDirectory ?? homedir)(), input.defaultDirectory ?? ".auth-store", input.defaultFileName ?? "credentials.enc");
24
+ if (input.filePath === undefined) {
25
+ const homeDirectory = (input.getHomeDirectory ?? homedir)();
26
+ const defaultDirectory = input.defaultDirectory ?? ".auth-store";
27
+ this.filePath = path.join(homeDirectory, defaultDirectory, input.defaultFileName ?? "credentials.enc");
28
+ this.symbolicLinkCheckStartPath = resolveDefaultDirectoryCheckStart(homeDirectory, defaultDirectory);
29
+ }
30
+ else {
31
+ this.filePath = input.filePath;
32
+ this.symbolicLinkCheckStartPath = null;
33
+ }
24
34
  this.getMachineIdentity = input.getMachineIdentity ?? defaultMachineIdentity;
25
35
  this.getRandomBytes = input.getRandomBytes ?? randomBytes;
26
36
  }
@@ -106,7 +116,7 @@ export class EncryptedFileStore {
106
116
  }
107
117
  async assertRegularCredentialPath() {
108
118
  const resolvedPath = path.resolve(this.filePath);
109
- const protectedPaths = [path.dirname(resolvedPath), resolvedPath];
119
+ const protectedPaths = getProtectedCredentialPaths(resolvedPath, this.symbolicLinkCheckStartPath);
110
120
  for (const currentPath of protectedPaths) {
111
121
  try {
112
122
  const stats = await this.fs.lstat(currentPath);
@@ -135,6 +145,30 @@ export class EncryptedFileStore {
135
145
  return this.keyPromise;
136
146
  }
137
147
  }
148
+ function resolveDefaultDirectoryCheckStart(homeDirectory, defaultDirectory) {
149
+ const [firstSegment] = defaultDirectory.split(/[\\/]+/).filter(Boolean);
150
+ return path.resolve(homeDirectory, firstSegment ?? ".");
151
+ }
152
+ function getProtectedCredentialPaths(resolvedPath, symbolicLinkCheckStartPath) {
153
+ if (symbolicLinkCheckStartPath === null) {
154
+ return [path.dirname(resolvedPath), resolvedPath];
155
+ }
156
+ const resolvedStartPath = path.resolve(symbolicLinkCheckStartPath);
157
+ if (!isPathInsideOrEqual(resolvedPath, resolvedStartPath)) {
158
+ return [path.dirname(resolvedPath), resolvedPath];
159
+ }
160
+ const protectedPaths = [resolvedStartPath];
161
+ let currentPath = resolvedStartPath;
162
+ for (const segment of path.relative(resolvedStartPath, resolvedPath).split(path.sep).filter(Boolean)) {
163
+ currentPath = path.join(currentPath, segment);
164
+ protectedPaths.push(currentPath);
165
+ }
166
+ return protectedPaths;
167
+ }
168
+ function isPathInsideOrEqual(childPath, parentPath) {
169
+ const relativePath = path.relative(parentPath, childPath);
170
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
171
+ }
138
172
  async function removeIfPresent(fileSystem, filePath) {
139
173
  try {
140
174
  await fileSystem.unlink(filePath);
@@ -515,6 +515,7 @@ export declare class SseParser {
515
515
  export interface JsonRpcRequestOptions {
516
516
  timeoutMs?: number;
517
517
  onRequestId?: (requestId: RequestId) => void;
518
+ onTimeout?: (requestId: RequestId) => void;
518
519
  }
519
520
  interface JsonRpcRequestContext {
520
521
  id: RequestId;
@@ -258,10 +258,19 @@ export class McpClient {
258
258
  }
259
259
  try {
260
260
  let requestId;
261
+ let cancellationSent = false;
262
+ const sendCancellationNotification = () => {
263
+ if (requestId === undefined || cancellationSent) {
264
+ return;
265
+ }
266
+ cancellationSent = true;
267
+ messageLayer.sendNotification("notifications/cancelled", { requestId });
268
+ };
261
269
  const requestPromise = messageLayer.sendRequest("tools/call", requestParams, {
262
270
  onRequestId: (nextRequestId) => {
263
271
  requestId = nextRequestId;
264
272
  },
273
+ onTimeout: sendCancellationNotification,
265
274
  }).then((result) => {
266
275
  if (!isCallToolResult(result)) {
267
276
  throw new McpError(ERROR_INVALID_REQUEST, "Invalid tool result");
@@ -275,9 +284,7 @@ export class McpClient {
275
284
  let abortListener;
276
285
  const abortPromise = new Promise((_, reject) => {
277
286
  const rejectWithAbortReason = () => {
278
- if (requestId !== undefined) {
279
- messageLayer.sendNotification("notifications/cancelled", { requestId });
280
- }
287
+ sendCancellationNotification();
281
288
  reject(signal.reason);
282
289
  };
283
290
  abortListener = rejectWithAbortReason;
@@ -2147,6 +2154,7 @@ export class JsonRpcMessageLayer {
2147
2154
  return new Promise((resolve, reject) => {
2148
2155
  const timeout = setTimeout(() => {
2149
2156
  this.pendingRequests.delete(id);
2157
+ options.onTimeout?.(id);
2150
2158
  reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
2151
2159
  }, timeoutMs);
2152
2160
  this.pendingRequests.set(id, { resolve, reject, timeout });
@@ -449,10 +449,19 @@ export class McpClient {
449
449
 
450
450
  try {
451
451
  let requestId: RequestId | undefined;
452
+ let cancellationSent = false;
453
+ const sendCancellationNotification = () => {
454
+ if (requestId === undefined || cancellationSent) {
455
+ return;
456
+ }
457
+ cancellationSent = true;
458
+ messageLayer.sendNotification("notifications/cancelled", { requestId });
459
+ };
452
460
  const requestPromise = messageLayer.sendRequest("tools/call", requestParams, {
453
461
  onRequestId: (nextRequestId) => {
454
462
  requestId = nextRequestId;
455
463
  },
464
+ onTimeout: sendCancellationNotification,
456
465
  }).then((result) => {
457
466
  if (!isCallToolResult(result)) {
458
467
  throw new McpError(ERROR_INVALID_REQUEST, "Invalid tool result");
@@ -468,9 +477,7 @@ export class McpClient {
468
477
  let abortListener: (() => void) | undefined;
469
478
  const abortPromise = new Promise<CallToolResult>((_, reject) => {
470
479
  const rejectWithAbortReason = () => {
471
- if (requestId !== undefined) {
472
- messageLayer.sendNotification("notifications/cancelled", { requestId });
473
- }
480
+ sendCancellationNotification();
474
481
  reject(signal.reason);
475
482
  };
476
483
 
@@ -3134,6 +3141,7 @@ interface ActiveIncomingRequest {
3134
3141
  export interface JsonRpcRequestOptions {
3135
3142
  timeoutMs?: number;
3136
3143
  onRequestId?: (requestId: RequestId) => void;
3144
+ onTimeout?: (requestId: RequestId) => void;
3137
3145
  }
3138
3146
 
3139
3147
  interface JsonRpcRequestContext {
@@ -3242,6 +3250,7 @@ export class JsonRpcMessageLayer {
3242
3250
  return new Promise((resolve, reject) => {
3243
3251
  const timeout = setTimeout(() => {
3244
3252
  this.pendingRequests.delete(id);
3253
+ options.onTimeout?.(id);
3245
3254
  reject(new Error(`JSON-RPC request "${method}" timed out after ${timeoutMs}ms`));
3246
3255
  }, timeoutMs);
3247
3256
 
@@ -293,6 +293,38 @@ describe("McpClient SDK integration callTool", () => {
293
293
  }
294
294
  });
295
295
 
296
+ it("cancels an in-flight slow tool call when the request timeout elapses", async () => {
297
+ const server = await createMockSlowToolServer({ delayMs: 1_000, pollIntervalMs: 5 });
298
+ const { client, cleanup } = await createSdkTestPair(server, () =>
299
+ new McpClient({
300
+ clientInfo: {
301
+ name: "test-client",
302
+ version: "1.0.0",
303
+ },
304
+ requestTimeoutMs: 100,
305
+ })
306
+ );
307
+
308
+ try {
309
+ const callPromise = client.callTool({
310
+ name: "slow",
311
+ arguments: {
312
+ delayMs: 500,
313
+ },
314
+ });
315
+
316
+ await waitFor(() => server.wasStarted(), "Timed out waiting for slow tool to start");
317
+
318
+ await expect(callPromise).rejects.toThrow(
319
+ 'JSON-RPC request "tools/call" timed out after 100ms'
320
+ );
321
+ await waitFor(() => server.wasCancelled(), "Timed out waiting for slow tool cancellation");
322
+ expect(server.getCancelledRequestIds()).toEqual(server.getStartedRequestIds());
323
+ } finally {
324
+ await cleanup();
325
+ }
326
+ });
327
+
296
328
  it("rejects with JSON-RPC error code and message for unknown tool names", async () => {
297
329
  const server = await createMockErrorServer();
298
330
  const { client, cleanup } = await createSdkTestPair(server, () =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -45,7 +45,7 @@
45
45
  "dependencies": {
46
46
  "@clack/core": "^1.0.0",
47
47
  "@clack/prompts": "^1.0.0",
48
- "toolcraft-schema": "0.0.24",
48
+ "toolcraft-schema": "0.0.25",
49
49
  "commander": "^14.0.3",
50
50
  "jose": "^6.1.2",
51
51
  "jsonc-parser": "^3.3.1",