pi-crew 0.6.0 → 0.6.3
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.
- package/CHANGELOG.md +225 -0
- package/README.md +70 -31
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/contracts.ts +2 -1
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- 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
|
-
|
|
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 (
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
185
|
+
// FIX Issue 3: Gracefully prune oldest entries instead of throwing when cap is exceeded.
|
|
140
186
|
if (entries.length > max) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
* This prevents unbounded growth
|
|
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
|
-
|
|
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 ?? ""))
|
|
178
|
-
|
|
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 {
|
|
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
|
-
|
|
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))
|
|
327
|
+
if (!isSafePathId(runId)) {
|
|
328
|
+
console.warn(`unregisterActiveRun: invalid runId ignored: ${runId}`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
214
331
|
withRegistryLock(() => {
|
|
215
|
-
|
|
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
|
-
|
|
226
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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())
|
|
118
|
-
|
|
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
|
-
|
|
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
|
+
}
|