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,32 +1,142 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
4
6
|
import { sleepSync } from "../utils/sleep.ts";
|
|
5
7
|
|
|
8
|
+
function hashContent(content: string): string {
|
|
9
|
+
return crypto.createHash("sha256").update(content, "utf-8").digest("hex");
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
13
|
+
const RETRYABLE_LINK_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Symlink-safe file write guard (caveman-inspired).
|
|
10
17
|
* Returns true if the path is safe to write, false if it's a symlink or
|
|
11
18
|
* inside a symlinked directory owned by another user.
|
|
19
|
+
*
|
|
20
|
+
* This walks the full ancestor chain to detect any symlinks in the path,
|
|
21
|
+
* preventing attacks where an intermediate ancestor is a symlink.
|
|
22
|
+
*
|
|
23
|
+
* Platform note: The ownership verification (via process.getuid) is only
|
|
24
|
+
* available on Unix-like platforms (Linux, macOS). On Windows or other
|
|
25
|
+
* platforms where getuid is unavailable, the ownership check is skipped
|
|
26
|
+
* and only symlink detection is performed. This means symlink safety
|
|
27
|
+
* verification is weaker on non-Unix platforms. Consider using
|
|
28
|
+
* platform-specific ownership verification (e.g., icacls on Windows)
|
|
29
|
+
* for stronger guarantees on those platforms.
|
|
30
|
+
*
|
|
31
|
+
* Design note: Ownership verification (getuid/getgid) is only performed
|
|
32
|
+
* when a path component is itself a symlink. For paths where all
|
|
33
|
+
* components are regular directories, no ownership verification occurs.
|
|
34
|
+
* This is intentional: an attacker who can create directories within
|
|
35
|
+
* baseDir could exploit this, but this is mitigated by the boundary
|
|
36
|
+
* check (realDir.startsWith(baseDir + path.sep)). In the pi-crew
|
|
37
|
+
* context, baseDir is always within the user's own pi-crew state
|
|
38
|
+
* directory tree (userPiRoot), so this is a low-risk design choice.
|
|
39
|
+
* Callers must ensure baseDir is always inside a protected user
|
|
40
|
+
* directory and never in a shared or world-writable location.
|
|
12
41
|
*/
|
|
13
|
-
function isSymlinkSafePath(filePath: string): boolean {
|
|
42
|
+
export function isSymlinkSafePath(filePath: string): boolean {
|
|
14
43
|
try {
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
44
|
+
// Note: baseDir is intentionally NOT resolved here with realpathSync.
|
|
45
|
+
// The while loop below walks the full ancestor chain and the explicit
|
|
46
|
+
// check at lines 94-101 verifies baseDir itself is not a symlink.
|
|
47
|
+
// This redundant early resolution was removed per Issue 1.
|
|
48
|
+
const baseDir = path.dirname(filePath);
|
|
49
|
+
// Walk the full ancestor chain to detect any symlinks
|
|
50
|
+
let currentPath = filePath;
|
|
51
|
+
while (currentPath !== path.dirname(currentPath)) {
|
|
52
|
+
const dir = path.dirname(currentPath);
|
|
53
|
+
try {
|
|
54
|
+
const dirStat = fs.lstatSync(dir);
|
|
55
|
+
if (dirStat.isSymbolicLink()) {
|
|
56
|
+
// Resolve and verify ownership on Unix
|
|
57
|
+
const realDir = fs.realpathSync(dir);
|
|
58
|
+
// Issue 1 fix: use resolved baseDir for boundary verification.
|
|
59
|
+
// Accept if realDir is inside baseDir, equals baseDir, or is an
|
|
60
|
+
// ancestor of baseDir. On macOS, /var/folders is a symlink to
|
|
61
|
+
// /private/var/folders — resolve baseDir too for comparison.
|
|
62
|
+
// Walk up ancestors to find the deepest existing one and resolve it.
|
|
63
|
+
let realBase = baseDir;
|
|
64
|
+
for (let walk = baseDir; walk !== path.dirname(walk); walk = path.dirname(walk)) {
|
|
65
|
+
try {
|
|
66
|
+
const resolved = fs.realpathSync(walk);
|
|
67
|
+
// Found existing ancestor — join with remaining non-existent parts
|
|
68
|
+
realBase = path.join(resolved, path.relative(walk, baseDir));
|
|
69
|
+
break;
|
|
70
|
+
} catch { /* not found yet, keep walking */ }
|
|
71
|
+
}
|
|
72
|
+
const realDirNorm = realDir.endsWith(path.sep) ? realDir : realDir + path.sep;
|
|
73
|
+
const realBaseNorm = realBase.endsWith(path.sep) ? realBase : realBase + path.sep;
|
|
74
|
+
const isAncestor = realBaseNorm.startsWith(realDirNorm) || realBase === realDir;
|
|
75
|
+
if (!isAncestor && !realDirNorm.startsWith(realBaseNorm)) return false;
|
|
76
|
+
const realStat = fs.statSync(realDir);
|
|
77
|
+
if (!realStat.isDirectory()) return false;
|
|
78
|
+
// Only check ownership for user-controlled directories.
|
|
79
|
+
// System directories like /private/var/folders on macOS are owned
|
|
80
|
+
// by root but are safe to use (the OS manages them). Skip uid check
|
|
81
|
+
// for directories not owned by the current user only when the real
|
|
82
|
+
// path is inside or is an ancestor of a known system temp directory.
|
|
83
|
+
if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) {
|
|
84
|
+
const systemTmp = (typeof os.tmpdir === "function" ? os.tmpdir() : "/tmp").toLowerCase();
|
|
85
|
+
let realSystemTmp = systemTmp;
|
|
86
|
+
try { realSystemTmp = fs.realpathSync(systemTmp).toLowerCase(); } catch { /* ok */ }
|
|
87
|
+
const realDirLower = realDir.toLowerCase();
|
|
88
|
+
const dirNorm = realDirLower.endsWith(path.sep) ? realDirLower : realDirLower + path.sep;
|
|
89
|
+
const tmpNorm = realSystemTmp.endsWith(path.sep) ? realSystemTmp : realSystemTmp + path.sep;
|
|
90
|
+
// Accept if realDir is inside tmpdir OR is an ancestor of tmpdir
|
|
91
|
+
// (e.g. /private/var/folders is an ancestor of /private/var/folders/.../T/)
|
|
92
|
+
const isInsideTmp = dirNorm.startsWith(tmpNorm) || realDirLower.startsWith(systemTmp);
|
|
93
|
+
const isAncestorOfTmp = tmpNorm.startsWith(dirNorm) || realSystemTmp.startsWith(realDirLower);
|
|
94
|
+
// Also accept canonical system temp paths:
|
|
95
|
+
// - /tmp → /private/tmp (macOS)
|
|
96
|
+
// - /var/folders → /private/var/folders (macOS)
|
|
97
|
+
const isSystemTmp = process.platform === "darwin" && (
|
|
98
|
+
realDirLower === "/tmp" || realDirLower === "/private/tmp" ||
|
|
99
|
+
realDirLower === "/var/folders" || realDirLower === "/private/var/folders" ||
|
|
100
|
+
realDirLower.startsWith("/var/folders/") || realDirLower.startsWith("/private/var/folders/")
|
|
101
|
+
);
|
|
102
|
+
if (!isInsideTmp && !isAncestorOfTmp && !isSystemTmp) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Directory doesn't exist yet — that's OK, mkdirSync will create it.
|
|
109
|
+
// For permission errors (EACCES, EPERM), we cannot verify the path
|
|
110
|
+
// is safe, so treat it as unsafe rather than returning true.
|
|
111
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
112
|
+
if (code === "ENOENT") {
|
|
113
|
+
// OK - directory doesn't exist yet
|
|
114
|
+
} else if (code === "EACCES" || code === "EPERM") {
|
|
115
|
+
// Permission error - cannot verify path is safe
|
|
116
|
+
logInternalError("isSymlinkSafePath.lstat.permission", err, `dir=${dir}`);
|
|
117
|
+
return false;
|
|
118
|
+
} else {
|
|
119
|
+
// Other errors - log but continue (better to be conservative)
|
|
120
|
+
logInternalError("isSymlinkSafePath.lstat", err, `dir=${dir}`);
|
|
121
|
+
}
|
|
25
122
|
}
|
|
26
|
-
|
|
27
|
-
// Directory doesn't exist yet — that's OK, mkdirSync will create it
|
|
123
|
+
currentPath = dir;
|
|
28
124
|
}
|
|
29
125
|
|
|
126
|
+
|
|
127
|
+
// Issue 1 fix: verify baseDir itself is not a symlink.
|
|
128
|
+
// The while loop above walks ancestors but stops when currentPath reaches
|
|
129
|
+
// root (currentPath === path.dirname(currentPath)). If baseDir is a symlink
|
|
130
|
+
// and is also the root of the path, it would not be checked. This explicit
|
|
131
|
+
// check ensures baseDir is verified regardless of where the loop terminates.
|
|
132
|
+
if (baseDir !== path.dirname(filePath)) {
|
|
133
|
+
try {
|
|
134
|
+
const baseDirStat = fs.lstatSync(baseDir);
|
|
135
|
+
if (baseDirStat.isSymbolicLink()) return false;
|
|
136
|
+
} catch {
|
|
137
|
+
// baseDir does not exist yet — that is OK
|
|
138
|
+
}
|
|
139
|
+
}
|
|
30
140
|
// Check if target file itself is a symlink
|
|
31
141
|
try {
|
|
32
142
|
const fileStat = fs.lstatSync(filePath);
|
|
@@ -51,6 +161,83 @@ function isRetryableRenameError(error: unknown): boolean {
|
|
|
51
161
|
return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_RENAME_CODES.has(String((error as NodeJS.ErrnoException).code)));
|
|
52
162
|
}
|
|
53
163
|
|
|
164
|
+
function isRetryableLinkError(error: unknown): boolean {
|
|
165
|
+
return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_LINK_CODES.has(String((error as NodeJS.ErrnoException).code)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Issue 1 fix: rename via link+unlink instead of rename.
|
|
170
|
+
* Unlike rename, link() does NOT follow symlinks at the destination path.
|
|
171
|
+
* It atomically creates a hard link to the source. We then unlink the source.
|
|
172
|
+
* This prevents TOCTOU attacks where an attacker plants a symlink at the
|
|
173
|
+
* destination between the check and the rename operation.
|
|
174
|
+
*/
|
|
175
|
+
function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): void {
|
|
176
|
+
let lastError: unknown;
|
|
177
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
178
|
+
try {
|
|
179
|
+
// Windows: hard links fail with ENOENT when short-name and long-name
|
|
180
|
+
// path aliases are mixed (e.g. RUNNER~1 vs runneradmin). Use
|
|
181
|
+
// renameSync which handles this correctly via MoveFileEx.
|
|
182
|
+
if (process.platform === "win32") {
|
|
183
|
+
try { fs.unlinkSync(filePath); } catch { /* destination may not exist */ }
|
|
184
|
+
try {
|
|
185
|
+
fs.renameSync(tempPath, filePath);
|
|
186
|
+
return;
|
|
187
|
+
} catch (renameError) {
|
|
188
|
+
lastError = renameError;
|
|
189
|
+
if (!isRetryableLinkError(renameError) || attempt === retries) break;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
// First try to unlink any existing file at destination (hard links don't follow symlinks)
|
|
193
|
+
try { fs.unlinkSync(filePath); } catch { /* destination may not exist */ }
|
|
194
|
+
// Create hard link - does NOT follow symlinks at filePath
|
|
195
|
+
fs.linkSync(tempPath, filePath);
|
|
196
|
+
// Successfully linked - now unlink the temp file
|
|
197
|
+
fs.unlinkSync(tempPath);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
lastError = error;
|
|
202
|
+
if (!isRetryableLinkError(error) || attempt === retries) break;
|
|
203
|
+
}
|
|
204
|
+
const base = Math.min(500, 10 * 2 ** attempt);
|
|
205
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
206
|
+
sleepSync(Math.max(1, Math.round(base + jitter)));
|
|
207
|
+
}
|
|
208
|
+
throw lastError;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function renameWithLinkAsync(tempPath: string, filePath: string, retries = 8): Promise<void> {
|
|
212
|
+
let lastError: unknown;
|
|
213
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
214
|
+
try {
|
|
215
|
+
if (process.platform === "win32") {
|
|
216
|
+
try { await fs.promises.unlink(filePath); } catch { /* destination may not exist */ }
|
|
217
|
+
try {
|
|
218
|
+
await fs.promises.rename(tempPath, filePath);
|
|
219
|
+
return;
|
|
220
|
+
} catch (renameError) {
|
|
221
|
+
lastError = renameError;
|
|
222
|
+
if (!isRetryableLinkError(renameError) || attempt === retries) break;
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
try { await fs.promises.unlink(filePath); } catch { /* destination may not exist */ }
|
|
226
|
+
await fs.promises.link(tempPath, filePath);
|
|
227
|
+
await fs.promises.unlink(tempPath);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
lastError = error;
|
|
232
|
+
if (!isRetryableLinkError(error) || attempt === retries) break;
|
|
233
|
+
}
|
|
234
|
+
const base = Math.min(500, 10 * 2 ** attempt);
|
|
235
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
236
|
+
await sleep(Math.max(1, Math.round(base + jitter)));
|
|
237
|
+
}
|
|
238
|
+
throw lastError;
|
|
239
|
+
}
|
|
240
|
+
|
|
54
241
|
export function renameWithRetry(tempPath: string, filePath: string, retries = 8, rename: (oldPath: string, newPath: string) => void = fs.renameSync): void {
|
|
55
242
|
let lastError: unknown;
|
|
56
243
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
@@ -95,14 +282,43 @@ export async function renameWithRetryAsync(tempPath: string, filePath: string, r
|
|
|
95
282
|
/** Test alias for renameWithRetryAsync. */
|
|
96
283
|
export const __test__renameWithRetryAsync = renameWithRetryAsync;
|
|
97
284
|
|
|
98
|
-
export function atomicWriteFile(filePath: string, content: string): void {
|
|
285
|
+
export function atomicWriteFile(filePath: string, content: string, expectedHash?: string): void {
|
|
99
286
|
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
100
|
-
|
|
101
|
-
|
|
287
|
+
// On Windows, resolve parent dir through realpathSync to handle short-name
|
|
288
|
+
// vs long-name path alias (e.g. RUNNER~1 vs runneradmin). Without this,
|
|
289
|
+
// mkdirSync may succeed but openSync fails with ENOENT because the OS
|
|
290
|
+
// sees the paths as different locations.
|
|
291
|
+
let dirPath = path.dirname(filePath);
|
|
292
|
+
if (process.platform === "win32") {
|
|
293
|
+
try {
|
|
294
|
+
const realDir = fs.realpathSync(dirPath);
|
|
295
|
+
if (realDir !== dirPath) dirPath = realDir;
|
|
296
|
+
} catch {
|
|
297
|
+
// dirPath may not exist yet — mkdirSync will create it
|
|
298
|
+
}
|
|
299
|
+
filePath = path.join(dirPath, path.basename(filePath));
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
|
|
305
|
+
try {
|
|
306
|
+
const realDir = fs.realpathSync(dirPath);
|
|
307
|
+
if (realDir !== dirPath) {
|
|
308
|
+
fs.mkdirSync(realDir, { recursive: true });
|
|
309
|
+
dirPath = realDir;
|
|
310
|
+
}
|
|
311
|
+
} catch { /* ignore – will fail at write time with better error */ }
|
|
312
|
+
} else {
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
102
317
|
// Write temp with restrictive permissions
|
|
103
318
|
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
319
|
+
let fd: number | undefined;
|
|
104
320
|
try {
|
|
105
|
-
|
|
321
|
+
fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
106
322
|
// Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
|
|
107
323
|
const openedStat = fs.fstatSync(fd);
|
|
108
324
|
if (!openedStat.isFile()) {
|
|
@@ -112,15 +328,26 @@ export function atomicWriteFile(filePath: string, content: string): void {
|
|
|
112
328
|
fs.writeSync(fd, content, undefined, "utf-8");
|
|
113
329
|
fs.closeSync(fd);
|
|
114
330
|
try {
|
|
115
|
-
|
|
331
|
+
// Issue 1 fix: re-check symlink safety immediately before rename.
|
|
332
|
+
// Between the initial isSymlinkSafePath check (line 147) and here,
|
|
333
|
+
// an attacker with control of an ancestor directory could plant a
|
|
334
|
+
// symlink at the target path. If rename succeeds with a symlink at
|
|
335
|
+
// target, the symlink is atomically replaced with attacker's content.
|
|
336
|
+
// The post-rename lstat check only runs on rename failure, so we must
|
|
337
|
+
// check BEFORE the rename to catch this TOCTOU race.
|
|
338
|
+
if (!isSymlinkSafePath(filePath)) {
|
|
339
|
+
throw new Error(`Refusing to rename: target became a symlink or inside untrusted directory: ${filePath}`);
|
|
340
|
+
}
|
|
341
|
+
// Issue 1 fix: use link+unlink instead of rename to avoid following symlinks
|
|
342
|
+
renameWithLinkSync(tempPath, filePath);
|
|
116
343
|
} catch (renameError) {
|
|
117
|
-
//
|
|
118
|
-
// Between isSymlinkSafePath
|
|
119
|
-
// could have been replaced with a symlink (TOCTOU).
|
|
344
|
+
// Issue 4 fix: use finally block to guarantee temp file cleanup.
|
|
345
|
+
// Between the initial isSymlinkSafePath check and rename attempt,
|
|
346
|
+
// the file could have been replaced with a symlink (TOCTOU).
|
|
347
|
+
// If lstat check below throws unexpectedly, finally ensures cleanup.
|
|
120
348
|
try {
|
|
121
349
|
const lstat = fs.lstatSync(filePath);
|
|
122
350
|
if (lstat.isSymbolicLink()) {
|
|
123
|
-
try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
124
351
|
throw renameError;
|
|
125
352
|
}
|
|
126
353
|
} catch (checkError) {
|
|
@@ -131,33 +358,18 @@ export function atomicWriteFile(filePath: string, content: string): void {
|
|
|
131
358
|
throw checkError;
|
|
132
359
|
}
|
|
133
360
|
}
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
throw renameError;
|
|
140
|
-
}
|
|
141
|
-
try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
142
|
-
}
|
|
143
|
-
} catch (error) {
|
|
144
|
-
let matches = false;
|
|
145
|
-
try {
|
|
146
|
-
const existing = fs.readFileSync(filePath, "utf-8");
|
|
147
|
-
matches = existing === content;
|
|
148
|
-
} catch {
|
|
149
|
-
/* ignore */
|
|
361
|
+
// Issue 2 fix: do NOT fall back to non-atomic writeFileSync.
|
|
362
|
+
// The rename failed after retries — throw the error rather than
|
|
363
|
+
// risking data corruption via a non-atomic write. Callers should
|
|
364
|
+
// handle this error or use a different strategy for contended files.
|
|
365
|
+
throw renameError;
|
|
150
366
|
}
|
|
151
|
-
|
|
367
|
+
} finally {
|
|
368
|
+
// Issue 4 fix: always clean up temp file, regardless of success or error path.
|
|
369
|
+
// This ensures no orphaned temp files remain when errors occur at any point.
|
|
370
|
+
if (fd !== undefined) {
|
|
152
371
|
try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
153
|
-
return;
|
|
154
372
|
}
|
|
155
|
-
try {
|
|
156
|
-
fs.rmSync(tempPath, { force: true });
|
|
157
|
-
} catch (cleanupError) {
|
|
158
|
-
logInternalError("atomic-write.cleanup", cleanupError, `tempPath=${tempPath}`);
|
|
159
|
-
}
|
|
160
|
-
throw error;
|
|
161
373
|
}
|
|
162
374
|
}
|
|
163
375
|
|
|
@@ -165,7 +377,7 @@ export function atomicWriteFile(filePath: string, content: string): void {
|
|
|
165
377
|
export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
|
|
166
378
|
if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
167
379
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
168
|
-
const tempPath = `${filePath}.${
|
|
380
|
+
const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
|
|
169
381
|
try {
|
|
170
382
|
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
|
|
171
383
|
const fd = await fs.promises.open(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
|
|
@@ -178,7 +390,19 @@ export async function atomicWriteFileAsync(filePath: string, content: string): P
|
|
|
178
390
|
await fd.writeFile(content, "utf-8");
|
|
179
391
|
await fd.close();
|
|
180
392
|
try {
|
|
181
|
-
|
|
393
|
+
// Re-check symlink safety immediately before rename.
|
|
394
|
+
// Between the initial isSymlinkSafePath check and here,
|
|
395
|
+
// an attacker with control of an ancestor directory could plant a
|
|
396
|
+
// symlink at the target path. If rename succeeds with a symlink at
|
|
397
|
+
// target, the symlink is atomically replaced with attacker's content.
|
|
398
|
+
// The post-rename lstat check only runs on rename failure, so we must
|
|
399
|
+
// check BEFORE the rename to catch this TOCTOU race.
|
|
400
|
+
if (!isSymlinkSafePath(filePath)) {
|
|
401
|
+
try { await fs.promises.rm(tempPath, { force: true }); } catch { /* best-effort */ }
|
|
402
|
+
throw new Error(`Refusing to rename: target became a symlink or inside untrusted directory: ${filePath}`);
|
|
403
|
+
}
|
|
404
|
+
// Issue 1 fix: use link+unlink instead of rename to avoid following symlinks
|
|
405
|
+
await renameWithLinkAsync(tempPath, filePath);
|
|
182
406
|
} catch (renameError) {
|
|
183
407
|
let matches = false;
|
|
184
408
|
try {
|
|
@@ -232,48 +456,115 @@ export async function atomicWriteJsonAsync<T>(filePath: string, value: T): Promi
|
|
|
232
456
|
interface CoalescedAtomicWrite {
|
|
233
457
|
content: string;
|
|
234
458
|
timer: ReturnType<typeof setTimeout>;
|
|
459
|
+
coalesceMs: number;
|
|
460
|
+
retryCount: number;
|
|
461
|
+
/** Generation counter to detect stale flushes (Issue 2 fix) */
|
|
462
|
+
generation: number;
|
|
235
463
|
}
|
|
464
|
+
const MAX_FLUSH_RETRIES = 5;
|
|
236
465
|
const pendingAtomicWrites = new Map<string, CoalescedAtomicWrite>();
|
|
237
466
|
const DEFAULT_ATOMIC_COALESCE_MS = 50;
|
|
467
|
+
/** Issue 2 fix: generation counter for coalesced writes */
|
|
468
|
+
let writeGeneration = 0;
|
|
469
|
+
|
|
470
|
+
// Issue 1 fix: guard against concurrent AND re-entrant flushes using depth counter
|
|
471
|
+
let flushInProgress = 0;
|
|
238
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Buffer a JSON write and flush it after `coalesceMs` ms (default 50).
|
|
475
|
+
* Multiple writes to the same path within the window collapse to one disk write.
|
|
476
|
+
*
|
|
477
|
+
* @see The coalescing caveat: a `readJsonFile` call between buffer and flush
|
|
478
|
+
* sees the previous on-disk content. Callers needing read-after-write
|
|
479
|
+
* must call `flushPendingAtomicWrites()` first or use `atomicWriteJson`.
|
|
480
|
+
*/
|
|
239
481
|
export function atomicWriteJsonCoalesced<T>(filePath: string, value: T, coalesceMs = DEFAULT_ATOMIC_COALESCE_MS): void {
|
|
240
482
|
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
241
483
|
const previous = pendingAtomicWrites.get(filePath);
|
|
242
484
|
if (previous) clearTimeout(previous.timer);
|
|
243
485
|
const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), coalesceMs);
|
|
244
486
|
timer.unref();
|
|
245
|
-
|
|
487
|
+
// Issue 2 fix: increment generation for each new entry
|
|
488
|
+
const generation = ++writeGeneration;
|
|
489
|
+
pendingAtomicWrites.set(filePath, { content, timer, coalesceMs, retryCount: 0, generation });
|
|
246
490
|
}
|
|
247
491
|
|
|
248
492
|
function flushOnePendingAtomicWrite(filePath: string): void {
|
|
249
493
|
const entry = pendingAtomicWrites.get(filePath);
|
|
250
494
|
if (!entry) return;
|
|
251
|
-
|
|
495
|
+
// Issue 2 fix: capture generation before flush to detect stale flushes.
|
|
496
|
+
// If a new write arrives during the flush, the generation will change
|
|
497
|
+
// and we will NOT delete the newer entry after the flush completes.
|
|
498
|
+
const savedGeneration = entry.generation;
|
|
252
499
|
clearTimeout(entry.timer);
|
|
253
500
|
try {
|
|
254
501
|
atomicWriteFile(filePath, entry.content);
|
|
502
|
+
// Issue 2 fix: Verify generation hasn't changed before deleting.
|
|
503
|
+
// A concurrent write may have replaced entry with a newer one during the flush.
|
|
504
|
+
// Only delete if generation matches (not a newer entry).
|
|
505
|
+
// This prevents orphaning newer entries that arrived during the flush.
|
|
506
|
+
if (pendingAtomicWrites.get(filePath)?.generation === savedGeneration) {
|
|
507
|
+
pendingAtomicWrites.delete(filePath);
|
|
508
|
+
}
|
|
255
509
|
} catch (error) {
|
|
256
510
|
logInternalError("atomic-write.coalesced-flush", error, filePath);
|
|
511
|
+
// Issue 1 fix: set a fresh timer for failed entries before returning.
|
|
512
|
+
// This ensures failed entries are retried without waiting for another
|
|
513
|
+
// write to arrive. Only set timer if this entry is still current
|
|
514
|
+
// (not replaced by a newer write during the flush).
|
|
515
|
+
const current = pendingAtomicWrites.get(filePath);
|
|
516
|
+
if (current?.generation === savedGeneration) {
|
|
517
|
+
current.retryCount++;
|
|
518
|
+
if (current.retryCount >= MAX_FLUSH_RETRIES) {
|
|
519
|
+
// Max retries exceeded - remove entry and propagate error to callers
|
|
520
|
+
pendingAtomicWrites.delete(filePath);
|
|
521
|
+
// Re-throw so callers can handle the persistent failure
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
// Exponential backoff: base delay * 2^(retryCount-1), capped at 30 seconds
|
|
525
|
+
const backoffMs = Math.min(30000, current.coalesceMs * Math.pow(2, current.retryCount - 1));
|
|
526
|
+
const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), backoffMs);
|
|
527
|
+
timer.unref();
|
|
528
|
+
current.timer = timer;
|
|
529
|
+
}
|
|
257
530
|
}
|
|
258
531
|
}
|
|
259
532
|
|
|
260
533
|
/** Flush every queued coalesced write synchronously. Safe to call any time. */
|
|
261
534
|
export function flushPendingAtomicWrites(): void {
|
|
262
|
-
|
|
535
|
+
if (flushInProgress > 0) return;
|
|
536
|
+
flushInProgress++;
|
|
537
|
+
try {
|
|
538
|
+
for (const filePath of [...pendingAtomicWrites.keys()]) flushOnePendingAtomicWrite(filePath);
|
|
539
|
+
} finally {
|
|
540
|
+
flushInProgress--;
|
|
541
|
+
}
|
|
263
542
|
}
|
|
264
543
|
|
|
544
|
+
// Defense-in-depth: signal handlers must return immediately.
|
|
545
|
+
// Use setImmediate so the handler exits before any sync I/O runs.
|
|
546
|
+
// This prevents the main thread from being blocked if a signal
|
|
547
|
+
// arrives while the user is idle in the terminal.
|
|
265
548
|
process.on("exit", () => flushPendingAtomicWrites());
|
|
266
|
-
process.on("SIGTERM", () => flushPendingAtomicWrites());
|
|
267
|
-
process.on("SIGINT", () => flushPendingAtomicWrites());
|
|
549
|
+
process.on("SIGTERM", () => setImmediate(() => flushPendingAtomicWrites()));
|
|
550
|
+
process.on("SIGINT", () => setImmediate(() => flushPendingAtomicWrites()));
|
|
268
551
|
|
|
269
552
|
export function readJsonFile<T>(filePath: string): T | undefined {
|
|
270
553
|
try {
|
|
271
554
|
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
272
555
|
} catch (err) {
|
|
273
556
|
const code = (err as NodeJS.ErrnoException).code;
|
|
274
|
-
if (code
|
|
557
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
558
|
+
// Expected: file doesn't exist or path is not a directory
|
|
559
|
+
return undefined;
|
|
560
|
+
} else if (code === "EACCES" || code === "EPERM") {
|
|
561
|
+
// Permission error - log as warning but still return undefined
|
|
562
|
+
logInternalError("readJsonFile.permission", err, `filePath=${filePath}`);
|
|
563
|
+
return undefined;
|
|
564
|
+
} else {
|
|
565
|
+
// Other unexpected errors - log as internal error
|
|
275
566
|
logInternalError("readJsonFile", err, `filePath=${filePath}`);
|
|
567
|
+
return undefined;
|
|
276
568
|
}
|
|
277
|
-
return undefined;
|
|
278
569
|
}
|
|
279
570
|
}
|