pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -1,9 +1,9 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
- import { serialize, deserialize } from "node:v8";
4
4
  import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
5
5
  import type { TeamRunManifest } from "./types.ts";
6
- import { atomicWriteJson } from "./atomic-write.ts";
6
+ import { atomicWriteJson, renameWithRetry, isSymlinkSafePath } from "./atomic-write.ts";
7
7
  import { userCrewRoot } from "../utils/paths.ts";
8
8
  import { isSafePathId } from "../utils/safe-paths.ts";
9
9
  import { sharedScanCache } from "../utils/scan-cache.ts";
@@ -13,6 +13,9 @@ import { logInternalError } from "../utils/internal-error.ts";
13
13
  /** Magic bytes prefix for binary registry to prevent deserialization of hostile files. */
14
14
  const BINARY_MAGIC = Buffer.from("PICREW2BIN", "utf-8");
15
15
 
16
+ /** Binary format version for forward compatibility. */
17
+ const BINARY_VERSION = 1;
18
+
16
19
  export interface ActiveRunRegistryEntry {
17
20
  runId: string;
18
21
  cwd: string;
@@ -64,7 +67,8 @@ function withRegistryLock<T>(fn: () => T): T {
64
67
  const staleMs = 30_000;
65
68
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
69
  let attempt = 0;
67
- const deadline = Date.now() + staleMs * 2;
70
+ // FIX Issue 3: Reduced timeout from staleMs*2 (60s) to 10s max for responsive shutdown.
71
+ const deadline = Date.now() + 10_000;
68
72
  while (true) {
69
73
  try {
70
74
  const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
@@ -109,16 +113,25 @@ function normalizeEntry(value: unknown): ActiveRunRegistryEntry | undefined {
109
113
 
110
114
  export function readActiveRunRegistry(maxEntries = DEFAULT_CACHE.manifestMaxEntries): ActiveRunRegistryEntry[] {
111
115
  let parsed: unknown;
112
- // 2.4 — prefer the binary mirror (single deserialize, no JSON.parse on
113
- // large arrays). Fall back to JSON when the binary is missing or
114
- // corrupt; this lets a 2-release migration co-exist with old readers.
116
+ // 2.4 — prefer the binary mirror (JSON after header for schema-safe deserialization).
117
+ // Fall back to JSON when the binary is missing or corrupt; this lets a 2-release
118
+ // migration co-exist with old readers.
115
119
  try {
116
120
  const buf = fs.readFileSync(registryBinaryPath());
117
- // Security: verify magic bytes before deserializing to prevent RCE from hostile files
121
+ // Security: verify magic bytes before parsing to prevent hostile files
118
122
  if (buf.length < BINARY_MAGIC.length || !buf.slice(0, BINARY_MAGIC.length).equals(BINARY_MAGIC)) {
119
123
  throw new Error("Invalid binary registry: missing magic bytes");
120
124
  }
121
- parsed = deserialize(buf.slice(BINARY_MAGIC.length));
125
+ // FIX Issue 1: Verify version field for forward compatibility.
126
+ const versionOffset = BINARY_MAGIC.length;
127
+ const version = buf.readUInt32BE(versionOffset);
128
+ if (version !== BINARY_VERSION) {
129
+ throw new Error(`Unsupported binary registry version: ${version}`);
130
+ }
131
+ // FIX Issue 1: Use JSON.parse instead of v8 deserialize for schema-safe parsing.
132
+ // The magic+version header provides integrity at the file level.
133
+ const jsonSlice = buf.slice(versionOffset + 4);
134
+ parsed = JSON.parse(jsonSlice.toString("utf-8"));
122
135
  } catch {
123
136
  try {
124
137
  parsed = JSON.parse(fs.readFileSync(registryPath(), "utf-8"));
@@ -134,50 +147,138 @@ export function readActiveRunRegistry(maxEntries = DEFAULT_CACHE.manifestMaxEntr
134
147
  return [...byId.values()].slice(0, Math.max(0, maxEntries));
135
148
  }
136
149
 
137
- function writeEntries(entries: ActiveRunRegistryEntry[]): void {
150
+ /**
151
+ * FIX Issues 1 & 2: Atomic binary write using O_CREAT|O_EXCL|O_NOFOLLOW pattern.
152
+ * Writes to temp file first, then renames. Includes version field for forward compatibility.
153
+ */
154
+ function atomicWriteBinary(filePath: string, entries: ActiveRunRegistryEntry[]): void {
155
+ if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write binary registry: target is a symlink or inside untrusted directory: ${filePath}`);
156
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
157
+ const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
158
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
159
+ const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
160
+ try {
161
+ // Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
162
+ const openedStat = fs.fstatSync(fd);
163
+ if (!openedStat.isFile()) {
164
+ fs.closeSync(fd);
165
+ throw new Error(`Refusing to write binary registry: opened path is not a regular file: ${tempPath}`);
166
+ }
167
+ // Write: magic (10 bytes) + version (4 bytes) + JSON data
168
+ const header = Buffer.allocUnsafe(14);
169
+ BINARY_MAGIC.copy(header, 0);
170
+ header.writeUInt32BE(BINARY_VERSION, BINARY_MAGIC.length);
171
+ // FIX Issue 1: Use JSON.stringify instead of v8 serialize for schema-safe output.
172
+ const jsonStr = JSON.stringify(entries);
173
+ const jsonBuf = Buffer.from(jsonStr, "utf-8");
174
+ fs.writeSync(fd, Buffer.concat([header, jsonBuf]));
175
+ fs.closeSync(fd);
176
+ renameWithRetry(tempPath, filePath);
177
+ } catch (error) {
178
+ try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ function writeEntries(entries: ActiveRunRegistryEntry[], skipTerminalRunIds?: Set<string>): void {
138
184
  const max = DEFAULT_CACHE.manifestMaxEntries;
139
- // FIX: Emit warning when entries overflow the cap, instead of silent drop.
185
+ // FIX Issue 3: Gracefully prune oldest entries instead of throwing when cap is exceeded.
140
186
  if (entries.length > max) {
141
- logInternalError(
142
- "active-run-registry.overflow",
143
- new Error(`${entries.length - max} entries dropped (cap=${max})`),
144
- JSON.stringify({ dropped: entries.length - max, total: entries.length, cap: max }),
145
- );
187
+ const sorted = [...entries].sort((a, b) => (a.updatedAt ?? "").localeCompare(b.updatedAt ?? ""));
188
+ entries = sorted.slice(entries.length - max);
189
+ }
190
+ // FIX Issue 1: Skip entries that transitioned to terminal since filterAliveEntries was called.
191
+ // This closes the TOCTOU race between status check in filterAliveEntries and write.
192
+ if (skipTerminalRunIds?.size) {
193
+ entries = entries.filter((e) => !skipTerminalRunIds.has(e.runId));
146
194
  }
147
- const trimmed = entries.slice(0, max);
148
195
  fs.mkdirSync(path.dirname(registryPath()), { recursive: true });
149
- // 2.4 dual-ship: write both formats. Readers prefer binary; legacy
150
- // readers (other tools / older releases) keep using the JSON file.
151
- atomicWriteJson(registryPath(), trimmed);
196
+ // FIX Issues 1 & 2: Write both to temp files first, then rename both atomically.
197
+ // If either rename fails, neither file is updated registry stays consistent.
198
+ const tempJson = `${registryPath()}.${crypto.randomUUID()}.tmp`;
199
+ const tempBin = `${registryBinaryPath()}.${crypto.randomUUID()}.tmp`;
200
+ let jsonRenamed = false;
201
+ let binRenamed = false;
152
202
  try {
153
- const tempBin = `${registryBinaryPath()}.${process.pid}.${Date.now()}.tmp`;
154
- fs.writeFileSync(tempBin, Buffer.concat([BINARY_MAGIC, serialize(trimmed)]));
155
- fs.renameSync(tempBin, registryBinaryPath());
203
+ // Write JSON to temp first
204
+ atomicWriteJson(tempJson, entries);
205
+ // Write binary to temp (atomic pattern)
206
+ atomicWriteBinary(tempBin, entries);
207
+ // Both written successfully — rename both atomically
208
+ renameWithRetry(tempJson, registryPath());
209
+ jsonRenamed = true;
210
+ renameWithRetry(tempBin, registryBinaryPath());
211
+ binRenamed = true;
156
212
  } catch (error) {
157
- logInternalError("active-run-registry.binary-write", error);
158
- }
213
+ // FIX Issue 5: Recovery when one rename succeeded — try to rename the other from its temp file
214
+ // instead of deleting both. This preserves whichever registry was successfully written.
215
+ if (jsonRenamed && !binRenamed) {
216
+ // JSON succeeded, binary failed — try to recover binary from temp
217
+ try { renameWithRetry(tempBin, registryBinaryPath()); binRenamed = true; } catch { /* recovery failed */ }
218
+ } else if (binRenamed && !jsonRenamed) {
219
+ // Binary succeeded, JSON failed — try to recover JSON from temp
220
+ try { renameWithRetry(tempJson, registryPath()); jsonRenamed = true; } catch { /* recovery failed */ }
221
+ }
222
+ // FIX Issue 2: If recovery failed, do NOT delete the final files — they may contain valid
223
+ // data from a prior write cycle. Leave temp files for manual recovery and throw error.
224
+ if (!binRenamed || !jsonRenamed) {
225
+ // Attempt to clean up temp files only (not final files)
226
+ try { fs.rmSync(tempJson, { force: true }); } catch { /* best-effort */ }
227
+ try { fs.rmSync(tempBin, { force: true }); } catch { /* best-effort */ }
228
+ throw error;
229
+ }
230
+ // FIX Issue 1: Ensure temp file cleanup executes even when recovery rename fails.
231
+ // If recovery throws, the error propagates before reaching the cleanup below.
232
+ // Wrapping in nested try-catch guarantees cleanup runs regardless of recovery outcome.
233
+ try { fs.rmSync(tempJson, { force: true }); } catch { /* best-effort */ }
234
+ try { fs.rmSync(tempBin, { force: true }); } catch { /* best-effort */ }
235
+ throw error;
236
+ }
159
237
  }
160
238
 
161
239
  const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "blocked"]);
162
240
 
163
241
  /**
164
- * Filter out entries that are no longer active: terminal status or missing manifest.
165
- * This prevents unbounded growth of the active-run-index.json file.
242
+ * FIX Issue 1: Filter out entries that are no longer active: terminal status, missing manifest,
243
+ * or symlink-unsafe paths. This prevents unbounded growth and guards against symlink attacks.
244
+ *
245
+ * WARNING: This function reads manifest files WITHOUT holding the registry lock (TOCTOU race).
246
+ * Status filtering is best-effort at read time. The terminal runIds set is returned so callers
247
+ * can pass them to writeEntries to skip entries that transitioned to terminal since filtering.
166
248
  */
167
- function filterAliveEntries(entries: ActiveRunRegistryEntry[]): ActiveRunRegistryEntry[] {
168
- return entries.filter((entry) => {
249
+ function filterAliveEntries(entries: ActiveRunRegistryEntry[]): { alive: ActiveRunRegistryEntry[]; terminalRunIds: Set<string> } {
250
+ const terminalRunIds = new Set<string>();
251
+ const alive = entries.filter((entry) => {
169
252
  try {
170
253
  if (!fs.existsSync(entry.cwd)) return false;
171
254
  if (!fs.existsSync(entry.manifestPath)) return false;
255
+ // FIX Issue 2: Guard against symlink attacks on stateRoot and manifestPath
256
+ if (!isSymlinkSafePath(entry.stateRoot)) return false;
257
+ if (!isSymlinkSafePath(entry.manifestPath)) return false;
172
258
  } catch {
173
259
  return false;
174
260
  }
175
261
  try {
176
262
  const raw = JSON.parse(fs.readFileSync(entry.manifestPath, "utf-8")) as { status?: string; async?: { pid?: number }; updatedAt?: string };
177
- if (TERMINAL_STATUSES.has(raw.status ?? "")) return false;
178
- // Dead PID = stale async run
263
+ if (TERMINAL_STATUSES.has(raw.status ?? "")) {
264
+ terminalRunIds.add(entry.runId);
265
+ return false;
266
+ }
267
+ // FIX Issue 2: Robust PID liveness check - only ESRCH means process is dead.
268
+ // EPERM means process exists but we can't signal it (different security context).
269
+ // Also check manifest age to guard against PID reuse.
179
270
  if (raw.async?.pid) {
180
- try { process.kill(raw.async.pid, 0); } catch { return false; }
271
+ try { process.kill(raw.async.pid, 0); } catch (err) {
272
+ // Only treat "process does not exist" as dead (ESRCH/ENOENT).
273
+ // EPERM means process is alive but protected; treat as alive.
274
+ const code = (err as NodeJS.ErrnoException).code;
275
+ if (code !== "ESRCH" && code !== "ENOENT") return false;
276
+ return false;
277
+ }
278
+ // FIX Issue 2: Async runs older than 30 min are stale even if PID is alive.
279
+ // This guards against PID reuse after the original process exits.
280
+ const updatedAt = typeof raw.updatedAt === 'string' ? Date.parse(raw.updatedAt) : NaN;
281
+ if (Number.isFinite(updatedAt) && Date.now() - updatedAt > 30 * 60 * 1000) return false;
181
282
  }
182
283
  // 2.19 — Stale non-async run: live-session/scaffold runs older than 30 min
183
284
  // Without this, test runs that crash/leak would stay in the registry forever.
@@ -190,6 +291,7 @@ function filterAliveEntries(entries: ActiveRunRegistryEntry[]): ActiveRunRegistr
190
291
  }
191
292
  return true;
192
293
  });
294
+ return { alive, terminalRunIds };
193
295
  }
194
296
 
195
297
  export function registerActiveRun(manifest: TeamRunManifest): void {
@@ -204,28 +306,58 @@ export function registerActiveRun(manifest: TeamRunManifest): void {
204
306
  const existing = readActiveRunRegistry().filter((item) => item.runId !== manifest.runId);
205
307
  // Inline cleanup: remove terminal-status and stale entries before writing.
206
308
  // This prevents unbounded growth between sessions.
207
- const alive = filterAliveEntries(existing);
208
- writeEntries([entry, ...alive]);
309
+ const { alive, terminalRunIds } = filterAliveEntries(existing);
310
+ // FIX Issue 4: Also filter the new entry being registered through filterAliveEntries
311
+ // to exclude it if it is terminal (e.g. completed between call and write).
312
+ const { alive: filteredAlive } = filterAliveEntries([entry]);
313
+ const filteredEntry = filteredAlive.length > 0 ? entry : null;
314
+ if (filteredEntry === null) {
315
+ throw new Error(`Cannot register run ${manifest.runId}: entry is terminal or paths are not symlink-safe`);
316
+ }
317
+ // FIX Issue 1: Pass terminal runIds from filterAliveEntries to writeEntries.
318
+ // writeEntries will skip these entries, closing the TOCTOU race between status check
319
+ // and write without re-reading manifest inside the lock.
320
+ const allTerminalRunIds = new Set(terminalRunIds);
321
+ if (filteredAlive.length === 0) allTerminalRunIds.add(entry.runId);
322
+ writeEntries([filteredEntry, ...alive], allTerminalRunIds);
209
323
  });
210
324
  }
211
325
 
212
326
  export function unregisterActiveRun(runId: string): void {
213
- if (!isSafePathId(runId)) return;
327
+ if (!isSafePathId(runId)) {
328
+ console.warn(`unregisterActiveRun: invalid runId ignored: ${runId}`);
329
+ return;
330
+ }
214
331
  withRegistryLock(() => {
215
- writeEntries(readActiveRunRegistry().filter((entry) => entry.runId !== runId));
332
+ // FIX Issue 1: Filter alive entries and pass terminal runIds to writeEntries,
333
+ // closing the TOCTOU race between status check and write (same pattern as registerActiveRun).
334
+ const { alive, terminalRunIds } = filterAliveEntries(readActiveRunRegistry().filter((entry) => entry.runId !== runId));
335
+ writeEntries(alive, terminalRunIds);
216
336
  });
217
337
  }
218
338
 
339
+ /**
340
+ * Status filtering is best-effort at read time — entries returned by this function may have
341
+ * transitioned to terminal status between the manifest read and the filter check.
342
+ * Callers must re-check status when opening individual run manifests.
343
+ */
219
344
  export function activeRunEntries(): ActiveRunRegistryEntry[] {
220
345
  const entries: ActiveRunRegistryEntry[] = [];
221
346
  for (const entry of readActiveRunRegistry()) {
222
347
  try {
223
348
  // Skip entries whose CWD no longer exists (temp test dirs, deleted projects)
224
349
  if (!fs.existsSync(entry.cwd)) continue;
225
- if (!fs.existsSync(entry.stateRoot) || !fs.existsSync(entry.manifestPath)) continue;
226
- if (fs.lstatSync(entry.stateRoot).isSymbolicLink()) continue;
350
+ // FIX Issue 4: sharedScanCache.readAndCache does stat checks internally.
351
+ // Only check stateRoot/manifestPath existence here if cache miss (cached.raw is undefined).
227
352
  const cached = sharedScanCache.readAndCache("active-manifests", entry.runId, entry.manifestPath);
228
- const manifest = (cached?.raw ?? JSON.parse(fs.readFileSync(entry.manifestPath, "utf-8"))) as { status?: unknown; updatedAt?: string; async?: { pid?: number } };
353
+ if (cached?.raw === undefined && (!fs.existsSync(entry.stateRoot) || !fs.existsSync(entry.manifestPath))) continue;
354
+ // FIX Issue 4: Check full ancestor chain for symlinks, not just immediate stateRoot.
355
+ if (!isSymlinkSafePath(entry.stateRoot)) continue;
356
+ // FIX Issue 4: Use cached manifest content directly when available.
357
+ // sharedScanCache.readAndCache already read the file; avoid redundant re-read.
358
+ const manifest = (cached?.raw !== undefined
359
+ ? cached.raw
360
+ : JSON.parse(fs.readFileSync(entry.manifestPath, "utf-8"))) as { status?: unknown; updatedAt?: string; async?: { pid?: number } };
229
361
  if (manifest.status !== "queued" && manifest.status !== "planning" && manifest.status !== "running" && manifest.status !== "blocked") continue;
230
362
  // PID liveness check: async runs with dead PID are stale — don't surface them
231
363
  if (manifest.async?.pid) {
@@ -52,7 +52,7 @@ function shouldCleanup(artifactsRoot: string, markerFile: string, scanGraceMs: n
52
52
 
53
53
  export function writeCleanupMarker(artifactsRoot: string, markerFile: string): void {
54
54
  fs.mkdirSync(artifactsRoot, { recursive: true });
55
- fs.writeFileSync(path.join(artifactsRoot, markerFile), String(nowMs()), "utf-8");
55
+ atomicWriteFile(path.join(artifactsRoot, markerFile), String(nowMs()));
56
56
  }
57
57
 
58
58
  export function cleanupOldArtifacts(artifactsRoot: string, options: ArtifactCleanupOptions): void {
@@ -97,31 +97,43 @@ export function cleanupOldArtifacts(artifactsRoot: string, options: ArtifactClea
97
97
  }
98
98
 
99
99
  function resolveInside(baseDir: string, relativePath: string): string {
100
+ // Check if baseDir is a symlink on every call to prevent symlink attacks
101
+ try {
102
+ if (fs.lstatSync(baseDir).isSymbolicLink()) throw new Error(`Artifacts root is a symbolic link — not allowed: ${baseDir}`);
103
+ } catch (err) {
104
+ // If lstatSync fails because baseDir doesn't exist yet, that's fine —
105
+ // it will be created by writeArtifact. Any other error should propagate.
106
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
107
+ }
108
+
100
109
  const normalizedRelativePath = relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
101
110
  if (!normalizedRelativePath || normalizedRelativePath.split("/").some((segment) => segment === "..") || path.isAbsolute(normalizedRelativePath)) {
102
111
  throw new Error(`Invalid artifact path: ${relativePath}`);
103
112
  }
104
- const base = path.resolve(baseDir);
105
- const resolved = path.resolve(base, normalizedRelativePath);
106
- const relative = path.relative(base, resolved);
107
- if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Invalid artifact path: ${relativePath}`);
108
- // C1: Extra normalization guard for case-insensitive / symlinked filesystems
109
- const normalized = path.normalize(resolved);
110
- if (!normalized.startsWith(base + path.sep) && normalized !== base) throw new Error(`Invalid artifact path (traversal): ${relativePath}`);
111
- return normalized;
113
+ // Use resolveRealContainedPath to resolve symlinks before checking containment,
114
+ // preventing TOCTOU attacks where intermediate directories are replaced with symlinks.
115
+ return resolveRealContainedPath(baseDir, normalizedRelativePath);
112
116
  }
113
117
 
114
118
  export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptions): ArtifactDescriptor {
115
- const filePath = resolveInside(artifactsRoot, options.relativePath);
119
+ // FIX: Create the artifactsRoot BEFORE calling resolveInside/resolveRealContainedPath,
120
+ // which open() the path to validate symlinks (fails with ENOENT if not yet created).
121
+ // Also reject symlinks in baseDir at this point (catches both pre-existing symlinks
122
+ // and the case where baseDir was created by mkdirSync as a regular dir).
116
123
  fs.mkdirSync(artifactsRoot, { recursive: true });
117
- if (fs.lstatSync(artifactsRoot).isSymbolicLink()) throw new Error(`Artifacts root is a symbolic link — not allowed: ${artifactsRoot}`);
118
- resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
124
+ if (fs.lstatSync(artifactsRoot).isSymbolicLink()) {
125
+ throw new Error(`Artifacts root is a symbolic link — not allowed: ${artifactsRoot}`);
126
+ }
127
+ const filePath = resolveInside(artifactsRoot, options.relativePath);
119
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
120
129
  resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
121
130
  const content = redactSecretString(options.content);
122
131
  atomicWriteFile(filePath, content);
123
132
  // Compute hash on written bytes for integrity verification.
124
- const contentHash = hashContent(content);
133
+ // Read back the actual file content to handle atomicWrite fallback path
134
+ // where the written content might differ from the input (e.g., concurrent writes).
135
+ const writtenContent = fs.readFileSync(filePath, "utf-8");
136
+ const contentHash = hashContent(writtenContent);
125
137
  const stats = fs.statSync(filePath);
126
138
  return {
127
139
  kind: options.kind,
@@ -0,0 +1,86 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ /**
6
+ * Fallow-inspired atomic writer: write-to-.tmp → fsync → rename.
7
+ *
8
+ * Key differences from atomic-write.ts:
9
+ * - Uses rename() (POSIX-atomic) instead of link()+unlink()
10
+ * - Calls fsyncSync() on the temp file before rename
11
+ * - Best-effort fsync (failure does not abort)
12
+ * - Writes .gitignore to directory on first use
13
+ * - UUID-based tmp file to prevent collisions under concurrent writes
14
+ */
15
+ export class AtomicWriter {
16
+ private initializedDirs = new Set<string>();
17
+ private baseDir: string;
18
+
19
+ constructor(baseDir: string) {
20
+ this.baseDir = baseDir;
21
+ }
22
+
23
+ writeSync(targetPath: string, content: string): void {
24
+ this.ensureParentDir(targetPath);
25
+ const tmpPath = this.tmpPath(targetPath);
26
+ const fd = fs.openSync(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
27
+ try {
28
+ fs.writeSync(fd, content, undefined, "utf8");
29
+ try { fs.fsyncSync(fd); } catch { /* best-effort */ }
30
+ } finally {
31
+ fs.closeSync(fd);
32
+ }
33
+ try {
34
+ fs.renameSync(tmpPath, targetPath);
35
+ } catch (err) {
36
+ try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ async writeAsync(targetPath: string, content: string): Promise<void> {
42
+ await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
43
+ const tmpPath = this.tmpPath(targetPath);
44
+ const fd = await fs.promises.open(tmpPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
45
+ try {
46
+ await fd.writeFile(content, "utf8");
47
+ try { await fd.sync(); } catch { /* best-effort */ }
48
+ } finally {
49
+ await fd.close();
50
+ }
51
+ try {
52
+ await fs.promises.rename(tmpPath, targetPath);
53
+ } catch (err) {
54
+ try { await fs.promises.unlink(tmpPath); } catch { /* best-effort cleanup */ }
55
+ throw err;
56
+ }
57
+ }
58
+
59
+ writeJsonSync<T>(targetPath: string, value: T): void {
60
+ this.writeSync(targetPath, JSON.stringify(value, null, 2) + "\n");
61
+ }
62
+
63
+ async writeJsonAsync<T>(targetPath: string, value: T): Promise<void> {
64
+ await this.writeAsync(targetPath, JSON.stringify(value, null, 2) + "\n");
65
+ }
66
+
67
+ private tmpPath(targetPath: string): string {
68
+ const uuid = crypto.randomUUID();
69
+ return `${targetPath}.${uuid}.tmp`;
70
+ }
71
+
72
+ private ensureParentDir(targetPath: string): void {
73
+ const dir = path.dirname(targetPath);
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ this.ensureGitignore(dir);
76
+ }
77
+
78
+ private ensureGitignore(dir: string): void {
79
+ if (this.initializedDirs.has(dir)) return;
80
+ this.initializedDirs.add(dir);
81
+ const gitignorePath = path.join(dir, ".gitignore");
82
+ try { fs.accessSync(gitignorePath); } catch {
83
+ try { fs.writeFileSync(gitignorePath, "*\n", "utf8"); } catch { /* best-effort */ }
84
+ }
85
+ }
86
+ }