pi-crew 0.6.1 → 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 +160 -0
- package/README.md +70 -33
- 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/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/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +2 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- 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 +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -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/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- 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/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- 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 +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- 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 +92 -70
- package/src/runtime/team-runner.ts +186 -20
- 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/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 +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -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 +330 -43
- 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 +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/worktree/cleanup.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { writeArtifact } from "../state/artifact-store.ts";
|
|
|
6
6
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
7
7
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
8
8
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
9
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
10
|
|
|
10
11
|
export interface WorktreeCleanupResult {
|
|
11
12
|
removed: string[];
|
|
@@ -15,17 +16,33 @@ export interface WorktreeCleanupResult {
|
|
|
15
16
|
committedBranches: string[];
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
|
|
20
|
+
// Git operations do not need PI_CREW_* execution-control vars.
|
|
21
|
+
const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "C", LC_ALL: "C" };
|
|
22
|
+
|
|
23
|
+
function sanitizeBranchPart(value: string): string {
|
|
24
|
+
return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sanitizeFilename(value: string): string {
|
|
28
|
+
// Strip control chars and newlines for safe artifact filenames
|
|
29
|
+
return value.slice(0, 200).replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ");
|
|
30
|
+
}
|
|
19
31
|
|
|
20
32
|
function git(cwd: string, args: string[]): string {
|
|
21
|
-
|
|
33
|
+
try {
|
|
34
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true }).trim();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw new Error(`git ${args.join(" ")} failed: ${message}`);
|
|
38
|
+
}
|
|
22
39
|
}
|
|
23
40
|
|
|
24
|
-
function isDirty(worktreePath: string): boolean {
|
|
41
|
+
function isDirty(worktreePath: string): boolean | "error" {
|
|
25
42
|
try {
|
|
26
43
|
return git(worktreePath, ["status", "--porcelain"]).trim().length > 0;
|
|
27
44
|
} catch {
|
|
28
|
-
return
|
|
45
|
+
return "error";
|
|
29
46
|
}
|
|
30
47
|
}
|
|
31
48
|
|
|
@@ -39,61 +56,112 @@ function captureDiff(worktreePath: string): string {
|
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?: boolean; signal?: AbortSignal } = {}): WorktreeCleanupResult {
|
|
42
|
-
const
|
|
59
|
+
const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
|
|
60
|
+
const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
|
|
43
61
|
const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [], committedBranches: [] };
|
|
44
62
|
if (!fs.existsSync(worktreeRoot)) return result;
|
|
45
63
|
|
|
46
64
|
// M3 fix: use withFileTypes to avoid race between readdirSync and statSync.
|
|
65
|
+
// Rely on Dirent.isDirectory() instead of a separate statSync to eliminate TOCTOU window.
|
|
47
66
|
const withFileTypes = fs.readdirSync(worktreeRoot, { withFileTypes: true });
|
|
48
67
|
for (const entry of withFileTypes) {
|
|
49
68
|
if (options.signal?.aborted) break;
|
|
50
69
|
if (!entry.isDirectory()) continue;
|
|
70
|
+
// Issue 1 fix: check signal before each git operation to respect abort faster
|
|
71
|
+
if (options.signal?.aborted) break;
|
|
51
72
|
const worktreePath = path.join(worktreeRoot, entry.name);
|
|
52
|
-
|
|
53
|
-
const stat = fs.statSync(worktreePath);
|
|
54
|
-
if (!stat.isDirectory()) continue;
|
|
55
|
-
} catch {
|
|
56
|
-
// Entry deleted between readdir and stat — skip safely.
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
73
|
+
if (options.signal?.aborted) break;
|
|
59
74
|
const dirty = isDirty(worktreePath);
|
|
60
|
-
const branchName = `pi-crew/${manifest.runId}/${entry.name}`;
|
|
75
|
+
const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
|
|
76
|
+
const safeBranchName = sanitizeBranchPart(entry.name);
|
|
61
77
|
if (dirty) {
|
|
78
|
+
// Issue 1 fix: check signal before git operations
|
|
79
|
+
if (options.signal?.aborted) break;
|
|
62
80
|
// Commit changes to a branch instead of just preserving the worktree
|
|
63
81
|
try {
|
|
82
|
+
// Issue 2 fix: verify status before and after add to ensure atomicity
|
|
83
|
+
const statusBefore = git(worktreePath, ["status", "--porcelain"]);
|
|
64
84
|
execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
85
|
+
// Verify no unexpected changes were added (only staged the original dirty files)
|
|
86
|
+
const statusAfterAdd = git(worktreePath, ["status", "--porcelain"]);
|
|
87
|
+
if (statusAfterAdd !== statusBefore) {
|
|
88
|
+
// Something changed between our status check and add - abort to avoid including unexpected changes
|
|
89
|
+
throw new Error("worktree state changed unexpectedly during add; refusing to commit");
|
|
90
|
+
}
|
|
91
|
+
// Issue 1 fix: check signal before commit
|
|
92
|
+
if (options.signal?.aborted) break;
|
|
65
93
|
let safeDesc = entry.name.slice(0, 200);
|
|
66
94
|
// SECURITY: Strip any newlines that could be injected via a malicious worktree name
|
|
67
95
|
// to prevent newline injection in git commit messages
|
|
68
96
|
if (safeDesc.includes("\n")) {
|
|
69
|
-
safeDesc = safeDesc.replace(/[\
|
|
97
|
+
safeDesc = safeDesc.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, " ");
|
|
70
98
|
}
|
|
71
99
|
execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
100
|
+
// Issue 1 fix: check signal before branch creation
|
|
101
|
+
if (options.signal?.aborted) break;
|
|
72
102
|
// Create branch in the main repo pointing to this worktree's HEAD
|
|
103
|
+
let branchError: Error | null = null;
|
|
73
104
|
try {
|
|
74
105
|
execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
75
|
-
} catch {
|
|
106
|
+
} catch (err) {
|
|
107
|
+
branchError = err instanceof Error ? err : new Error(String(err));
|
|
76
108
|
// Branch already exists — use timestamp suffix
|
|
77
109
|
const tsBranch = `${branchName}-${Date.now()}`;
|
|
78
|
-
|
|
110
|
+
try {
|
|
111
|
+
execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
|
|
112
|
+
} catch (err2) {
|
|
113
|
+
// Both branch attempts failed — accumulate error for outer catch
|
|
114
|
+
const err2_msg = err2 instanceof Error ? err2.message : String(err2);
|
|
115
|
+
throw new Error(`branch creation failed (${branchName} already exists): ${branchError.message}; fallback branch also failed: ${err2_msg}`);
|
|
116
|
+
}
|
|
79
117
|
}
|
|
80
118
|
result.committedBranches.push(branchName);
|
|
81
|
-
//
|
|
119
|
+
// Issue 1 fix: check signal before worktree remove
|
|
120
|
+
if (options.signal?.aborted) break;
|
|
121
|
+
// Remove the worktree (branch persists).
|
|
122
|
+
// NOTE: If git worktree remove fails here after the commit succeeded,
|
|
123
|
+
// the worktree directory remains on disk orphaned from the branch.
|
|
124
|
+
// The committed changes are safe in the branch and recoverable via:
|
|
125
|
+
// git branch -D <branchName> (to clean up the branch)
|
|
126
|
+
// rm -rf <worktreePath> (to clean up the orphaned directory)
|
|
82
127
|
const removeArgs = ["worktree", "remove", "--force", worktreePath];
|
|
83
|
-
git
|
|
84
|
-
|
|
128
|
+
// Capture git status before removal since worktree won't exist after successful remove
|
|
129
|
+
const gitStatusBeforeRemove = git(worktreePath, ["status", "--porcelain"]);
|
|
130
|
+
try {
|
|
131
|
+
git(manifest.cwd, removeArgs);
|
|
132
|
+
result.removed.push(worktreePath);
|
|
133
|
+
} catch (removeError) {
|
|
134
|
+
// Commit succeeded but worktree remove failed — directory is orphaned
|
|
135
|
+
// Issue 1 fix: use fs.rmSync as fallback to clean up orphaned directory
|
|
136
|
+
try {
|
|
137
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
138
|
+
result.removed.push(worktreePath);
|
|
139
|
+
} catch {
|
|
140
|
+
result.preserved.push({ path: worktreePath, reason: `commit succeeded but worktree remove failed: ${removeError instanceof Error ? removeError.message : String(removeError)}; fs.rmSync fallback also failed` });
|
|
141
|
+
}
|
|
142
|
+
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
143
|
+
kind: "metadata",
|
|
144
|
+
relativePath: `metadata/worktree-branch-${safeBranchName}.json`,
|
|
145
|
+
content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}`, gitStatusAtCommit: gitStatusBeforeRemove }, null, 2),
|
|
146
|
+
producer: "worktree-cleanup",
|
|
147
|
+
});
|
|
148
|
+
result.artifactPaths.push(artifact.path);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
85
151
|
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
86
152
|
kind: "metadata",
|
|
87
|
-
relativePath: `metadata/worktree-branch-${
|
|
88
|
-
content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}
|
|
153
|
+
relativePath: `metadata/worktree-branch-${safeBranchName}.json`,
|
|
154
|
+
content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}`, gitStatusAtCommit: gitStatusBeforeRemove }, null, 2),
|
|
89
155
|
producer: "worktree-cleanup",
|
|
90
156
|
});
|
|
91
157
|
result.artifactPaths.push(artifact.path);
|
|
92
158
|
} catch (error) {
|
|
93
159
|
// Fallback to preserving dirty worktree
|
|
160
|
+
// FIX: entry is a DirEnt object, must use entry.name
|
|
161
|
+
const safeFallbackName = sanitizeFilename(entry.name);
|
|
94
162
|
const artifact = writeArtifact(manifest.artifactsRoot, {
|
|
95
163
|
kind: "diff",
|
|
96
|
-
relativePath: `cleanup/${
|
|
164
|
+
relativePath: `cleanup/${safeFallbackName}.diff`,
|
|
97
165
|
content: captureDiff(worktreePath),
|
|
98
166
|
producer: "worktree-cleanup",
|
|
99
167
|
});
|
|
@@ -102,6 +170,10 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
|
|
|
102
170
|
}
|
|
103
171
|
continue;
|
|
104
172
|
}
|
|
173
|
+
// Issue 3 fix: sanitize entry.name before using in git commands
|
|
174
|
+
const safeEntryName = sanitizeBranchPart(entry.name);
|
|
175
|
+
// Issue 1 fix: check signal before non-dirty worktree remove
|
|
176
|
+
if (options.signal?.aborted) break;
|
|
105
177
|
const args = ["worktree", "remove"];
|
|
106
178
|
if (options.force) args.push("--force");
|
|
107
179
|
args.push(worktreePath);
|
|
@@ -115,9 +187,25 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
|
|
|
115
187
|
}
|
|
116
188
|
|
|
117
189
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
190
|
+
// Issue 2 fix: run git worktree prune to clean up any orphaned worktree references
|
|
191
|
+
// after processing all worktrees. This helps clean up directories left orphaned
|
|
192
|
+
// when git worktree remove failed after a successful commit.
|
|
193
|
+
git(manifest.cwd, ["worktree", "prune"]);
|
|
194
|
+
} catch (error) {
|
|
120
195
|
// Non-critical cleanup.
|
|
196
|
+
logInternalError("cleanup.worktreePrune", error instanceof Error ? error : new Error(String(error)));
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
// Issue 1 fix: Use rmdirSync which fails atomically on non-empty directories,
|
|
200
|
+
// avoiding TOCTOU race between readdirSync check and rmSync removal.
|
|
201
|
+
// Ignore ENOENT (already removed) and ENOTEMPTY (became non-empty between check and remove).
|
|
202
|
+
fs.rmdirSync(worktreeRoot);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Non-critical cleanup. Ignore ENOENT and ENOTEMPTY.
|
|
205
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
206
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY") {
|
|
207
|
+
// Optionally log unexpected errors in debug mode
|
|
208
|
+
}
|
|
121
209
|
}
|
|
122
210
|
return result;
|
|
123
211
|
}
|
|
@@ -25,11 +25,16 @@ export interface WorktreeDiffStat {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function git(cwd: string, args: string[]): string {
|
|
28
|
-
|
|
28
|
+
// SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
|
|
29
|
+
// Git operations do not need PI_CREW_* execution-control vars.
|
|
30
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, windowsHide: true }).trim();
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// Dots are removed from branch names since they are used in path construction,
|
|
34
|
+
// and dots could cause ambiguity with relative path handling on some platforms.
|
|
35
|
+
// Branch names themselves support dots in git, but we strip them for safe path use.
|
|
31
36
|
function sanitizeBranchPart(value: string): string {
|
|
32
|
-
return value.toLowerCase().replace(/[^a-z0-
|
|
37
|
+
return value.toLowerCase().replace(/[^a-z0-9_/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export function findGitRoot(cwd: string): string {
|
|
@@ -64,7 +69,7 @@ function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boole
|
|
|
64
69
|
function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
|
|
65
70
|
const resolved = path.resolve(worktreePath, rawPath);
|
|
66
71
|
const relative = path.relative(worktreePath, resolved);
|
|
67
|
-
if (!relative || relative
|
|
72
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
|
|
68
73
|
return path.normalize(relative);
|
|
69
74
|
}
|
|
70
75
|
|
|
@@ -118,6 +123,12 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
118
123
|
}
|
|
119
124
|
// SECURITY WARNING: Home directory hooks (~/.pi/hooks/) are user-writable and not project-scoped.
|
|
120
125
|
// A rogue npm postinstall script could place malicious hooks there. Log for visibility.
|
|
126
|
+
//
|
|
127
|
+
// SECURITY ASSUMPTION: This function trusts that the hook scripts themselves are not malicious.
|
|
128
|
+
// Hook scripts are executed with the same privileges as the Pi process. The caller is responsible
|
|
129
|
+
// for ensuring that only trusted hook scripts are configured. Path containment validation
|
|
130
|
+
// (isAllowedSetupHook, isHookPathContainedInRepoRoot) prevents hook scripts from writing outside
|
|
131
|
+
// the worktree, but cannot prevent a trusted hook from performing harmful operations within it.
|
|
121
132
|
if (path.isAbsolute(rawHookPath)) {
|
|
122
133
|
logInternalError("worktree.setupHook.homeHook", new Error("Home directory hook used — ensure ~/.pi/hooks/ is trusted"), `hookPath=${rawHookPath}`);
|
|
123
134
|
}
|
|
@@ -128,8 +139,14 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
128
139
|
logInternalError("worktree.setupHook.contained", new Error("hook path escapes repoRoot after realpath resolution: " + hookPath), `repoRoot=${repoRoot}`);
|
|
129
140
|
return [];
|
|
130
141
|
}
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
try {
|
|
143
|
+
const hookStat = fs.lstatSync(hookPath);
|
|
144
|
+
if (!hookStat.isFile()) {
|
|
145
|
+
logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
logInternalError("worktree.setupHook.missing", new Error("hook not found: " + hookPath), `cwd=${manifest.cwd}`);
|
|
133
150
|
return [];
|
|
134
151
|
}
|
|
135
152
|
const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
|
|
@@ -142,26 +159,41 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
142
159
|
if (process.platform === "win32" && !nodeHook && !isBatchFile) {
|
|
143
160
|
logInternalError("worktree.setupHook.windowsNoShell", new Error("Non-node, non-batch hook skipped on Windows (shell:true disabled for security)"), `hook=${hookPath}`);
|
|
144
161
|
}
|
|
162
|
+
// SECURITY: Resolve the hook to its real path before execution to close the TOCTOU window.
|
|
163
|
+
// This prevents a symlink swap between the containment check and actual execution.
|
|
164
|
+
// KNOWN LIMITATION: There is a residual TOCTOU window between realpathSync validation
|
|
165
|
+
// (line 166) and spawnSync execution (line 183). A sufficiently fast attacker could
|
|
166
|
+
// theoretically swap the symlink between these two operations. The realpathSync + O_NOFOLLOW
|
|
167
|
+
// approach minimizes but does not eliminate this window. To fully close it, consider
|
|
168
|
+
// opening the hook file once via a file descriptor and executing via fd passing (not available
|
|
169
|
+
// in Node.js spawnSync). This is documented as a known limitation rather than a bug.
|
|
170
|
+
let realHookPath: string;
|
|
171
|
+
try {
|
|
172
|
+
realHookPath = fs.realpathSync(hookPath);
|
|
173
|
+
} catch {
|
|
174
|
+
logInternalError("worktree.setupHook.realpath", new Error("hook realpath resolution failed: " + hookPath), `cwd=${manifest.cwd}`);
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
145
177
|
const result = isBatchFile
|
|
146
|
-
? spawnSync("cmd.exe", ["/c",
|
|
178
|
+
? spawnSync("cmd.exe", ["/c", realHookPath], {
|
|
147
179
|
cwd: worktreePath,
|
|
148
180
|
encoding: "utf-8",
|
|
149
181
|
input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
|
|
150
182
|
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
151
183
|
shell: false, // cmd.exe /c handles batch files safely
|
|
152
184
|
env: sanitizeEnvSecrets(process.env, {
|
|
153
|
-
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"
|
|
185
|
+
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
|
|
154
186
|
}),
|
|
155
187
|
windowsHide: true,
|
|
156
188
|
})
|
|
157
|
-
: spawnSync(nodeHook ? process.execPath :
|
|
189
|
+
: spawnSync(nodeHook ? process.execPath : realHookPath, nodeHook ? [realHookPath] : [], {
|
|
158
190
|
cwd: worktreePath,
|
|
159
191
|
encoding: "utf-8",
|
|
160
192
|
input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
|
|
161
193
|
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
162
|
-
shell:
|
|
194
|
+
shell: false,
|
|
163
195
|
env: sanitizeEnvSecrets(process.env, {
|
|
164
|
-
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"
|
|
196
|
+
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
|
|
165
197
|
}),
|
|
166
198
|
windowsHide: true,
|
|
167
199
|
});
|
|
@@ -226,6 +258,24 @@ export function normalizeSeedPaths(seedPaths: string[], repoRoot: string): strin
|
|
|
226
258
|
throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`);
|
|
227
259
|
}
|
|
228
260
|
|
|
261
|
+
// Reject symlinks to prevent escape via symlink-based path traversal.
|
|
262
|
+
// This check is also performed in overlaySeedPaths for defense-in-depth.
|
|
263
|
+
// ENOENT is acceptable — seed paths may reference files that don't exist yet
|
|
264
|
+
// (they are validated at copy time by overlaySeedPaths).
|
|
265
|
+
try {
|
|
266
|
+
const stat = fs.lstatSync(absolutePath);
|
|
267
|
+
if (stat.isSymbolicLink()) {
|
|
268
|
+
throw new Error(`seedPaths entries cannot be symlinks: ${entry}`);
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error instanceof Error && error.message.startsWith("seedPaths entries")) throw error;
|
|
272
|
+
// ENOENT is acceptable — seed paths may reference files that don't exist yet.
|
|
273
|
+
// Skip symlink check but still include the path in results.
|
|
274
|
+
if (!(error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")) {
|
|
275
|
+
throw new Error(`seedPaths entries must be accessible: ${entry}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
229
279
|
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
230
280
|
if (seen.has(normalizedPath)) continue;
|
|
231
281
|
seen.add(normalizedPath);
|
|
@@ -247,15 +297,27 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
|
|
|
247
297
|
const sourcePath = path.join(repoRoot, seedPath);
|
|
248
298
|
const destinationPath = path.join(worktreePath, seedPath);
|
|
249
299
|
|
|
250
|
-
|
|
300
|
+
let sourceStat: fs.Stats;
|
|
301
|
+
try {
|
|
302
|
+
sourceStat = fs.lstatSync(sourcePath);
|
|
303
|
+
} catch {
|
|
251
304
|
logInternalError("worktree.seedPaths.missing", new Error(`Seed path does not exist: ${seedPath}`));
|
|
252
305
|
continue;
|
|
253
306
|
}
|
|
307
|
+
// Reject symlinks in seed paths to prevent copying symlinks into worktree (which could point outside repoRoot).
|
|
308
|
+
if (sourceStat.isSymbolicLink()) {
|
|
309
|
+
logInternalError("worktree.seedPaths.symlink", new Error(`Seed path is a symlink — rejected: ${seedPath}`));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
|
|
313
|
+
logInternalError("worktree.seedPaths.invalid", new Error(`Seed path is neither file nor directory: ${seedPath}`));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
254
316
|
|
|
255
317
|
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
256
318
|
fs.rmSync(destinationPath, { force: true, recursive: true });
|
|
257
319
|
fs.cpSync(sourcePath, destinationPath, {
|
|
258
|
-
dereference:
|
|
320
|
+
dereference: true,
|
|
259
321
|
force: true,
|
|
260
322
|
preserveTimestamps: true,
|
|
261
323
|
recursive: true,
|
|
@@ -268,11 +330,44 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
268
330
|
const repoRoot = findGitRoot(manifest.cwd);
|
|
269
331
|
const loadedConfig = loadConfig(manifest.cwd);
|
|
270
332
|
if (loadedConfig.config.requireCleanWorktreeLeader !== false) assertCleanLeader(repoRoot);
|
|
271
|
-
const
|
|
333
|
+
const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
|
|
334
|
+
const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
|
|
272
335
|
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
273
|
-
|
|
336
|
+
// Resolve through realpathSync.native to get long-name form on Windows.
|
|
337
|
+
// git worktree uses long-name paths, so we must match. If .native fails,
|
|
338
|
+
// fall back to non-native (preserves input form).
|
|
339
|
+
let resolvedWorktreeRoot = worktreeRoot;
|
|
340
|
+
try {
|
|
341
|
+
const r = fs.realpathSync.native(worktreeRoot);
|
|
342
|
+
resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r;
|
|
343
|
+
} catch {
|
|
344
|
+
try { resolvedWorktreeRoot = fs.realpathSync(worktreeRoot); } catch { /* keep as-is */ }
|
|
345
|
+
}
|
|
346
|
+
const sanitizedTaskId = sanitizeBranchPart(task.id);
|
|
347
|
+
const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId);
|
|
274
348
|
const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`;
|
|
275
|
-
|
|
349
|
+
// Use `git worktree list --porcelain` to atomically verify the worktree exists.
|
|
350
|
+
// This avoids a TOCTOU race between fs.existsSync and git branch verification.
|
|
351
|
+
let worktreeExists = false;
|
|
352
|
+
try {
|
|
353
|
+
const worktreeList = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
354
|
+
// `git worktree list --porcelain` outputs "worktree /path" per entry.
|
|
355
|
+
// We must compare against the path part (after "worktree ").
|
|
356
|
+
// On Windows, git may return forward-slash long-name paths while
|
|
357
|
+
// worktreePath uses short-name backslash form. Resolve both through
|
|
358
|
+
// realpathSync.native (which always returns long-name on Windows)
|
|
359
|
+
// for consistent comparison.
|
|
360
|
+
const normalizedWtPath = process.platform === "win32" ? (() => { try { const r = fs.realpathSync.native(worktreePath); return r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { return worktreePath; } })().replace(/\\/g, "/").toLowerCase() : worktreePath;
|
|
361
|
+
worktreeExists = worktreeList.split("\n").some((line) => {
|
|
362
|
+
const trimmed = line.trim();
|
|
363
|
+
const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed;
|
|
364
|
+
if (process.platform === "win32") {
|
|
365
|
+
return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath;
|
|
366
|
+
}
|
|
367
|
+
return matchPath === worktreePath;
|
|
368
|
+
});
|
|
369
|
+
} catch { worktreeExists = false; }
|
|
370
|
+
if (worktreeExists) {
|
|
276
371
|
let currentBranch: string;
|
|
277
372
|
try {
|
|
278
373
|
currentBranch = git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
@@ -282,16 +377,27 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
282
377
|
if (currentBranch !== branch) {
|
|
283
378
|
throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`);
|
|
284
379
|
}
|
|
380
|
+
// Check for uncommitted changes from previous run before reusing
|
|
381
|
+
const dirtyStatus = git(worktreePath, ["status", "--porcelain"]);
|
|
382
|
+
if (dirtyStatus.trim()) {
|
|
383
|
+
// Discard uncommitted changes to ensure clean slate for new task
|
|
384
|
+
logInternalError("worktree.reused.dirty", new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`);
|
|
385
|
+
git(worktreePath, ["checkout", "--", "."]);
|
|
386
|
+
git(worktreePath, ["clean", "-fd"]);
|
|
387
|
+
}
|
|
285
388
|
// Overlay seed paths from config + step-level seedPaths (reused worktree)
|
|
286
389
|
const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
|
|
287
390
|
const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
|
|
288
391
|
if (mergedReused.length > 0) {
|
|
289
392
|
overlaySeedPaths(repoRoot, worktreePath, mergedReused);
|
|
290
393
|
}
|
|
394
|
+
// Re-validate leader is still clean before reusing — leader state may have changed since first preparation
|
|
395
|
+
assertCleanLeader(repoRoot);
|
|
291
396
|
return { cwd: worktreePath, worktreePath, branch, reused: true };
|
|
292
397
|
}
|
|
293
398
|
pruneStaleWorktrees(repoRoot);
|
|
294
399
|
const exists = branchExists(repoRoot, branch);
|
|
400
|
+
let worktreeCreated = false;
|
|
295
401
|
try {
|
|
296
402
|
if (exists.local) {
|
|
297
403
|
git(repoRoot, ["worktree", "add", worktreePath, branch]);
|
|
@@ -301,7 +407,14 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
301
407
|
}
|
|
302
408
|
git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
|
|
303
409
|
}
|
|
410
|
+
worktreeCreated = true;
|
|
304
411
|
} catch (error) {
|
|
412
|
+
// Clean up orphaned worktree directory if git worktree add failed
|
|
413
|
+
if (fs.existsSync(worktreePath)) {
|
|
414
|
+
try {
|
|
415
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
416
|
+
} catch { /* best-effort cleanup */ }
|
|
417
|
+
}
|
|
305
418
|
const msg = error instanceof Error ? error.message : String(error);
|
|
306
419
|
if (/already checked out|is already used by worktree/i.test(msg)) {
|
|
307
420
|
throw new Error(`Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`);
|
package/test-bugs-all.mjs
CHANGED
|
@@ -69,13 +69,17 @@ if (completedIdsFix) {
|
|
|
69
69
|
|
|
70
70
|
// Check dist file
|
|
71
71
|
console.log("\n=== Checking dist/index.mjs ===");
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
if (fs.existsSync("dist/index.mjs")) {
|
|
73
|
+
const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
|
|
74
|
+
const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
|
|
75
|
+
if (distNeedsAttention) {
|
|
76
|
+
console.log(" ✅ Bug #20 fix is in dist/index.mjs");
|
|
77
|
+
} else {
|
|
78
|
+
console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
|
|
79
|
+
allPassed = false;
|
|
80
|
+
}
|
|
76
81
|
} else {
|
|
77
|
-
|
|
78
|
-
allPassed = false;
|
|
82
|
+
console.log(" ⚠️ dist/index.mjs not found - run npm run build first");
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
console.log("\n" + "=".repeat(40));
|
|
@@ -65,7 +65,9 @@ test("fast-fix team run completes successfully with mock child Pi", async () =>
|
|
|
65
65
|
|
|
66
66
|
const prevExec = process.env.PI_TEAMS_EXECUTE_WORKERS;
|
|
67
67
|
const prevMock = process.env.PI_TEAMS_MOCK_CHILD_PI;
|
|
68
|
+
const prevAllow = process.env.PI_CREW_ALLOW_MOCK;
|
|
68
69
|
process.env.PI_TEAMS_EXECUTE_WORKERS = "1";
|
|
70
|
+
process.env.PI_CREW_ALLOW_MOCK = "1";
|
|
69
71
|
process.env.PI_TEAMS_MOCK_CHILD_PI = "success";
|
|
70
72
|
|
|
71
73
|
try {
|
|
@@ -109,6 +111,8 @@ test("fast-fix team run completes successfully with mock child Pi", async () =>
|
|
|
109
111
|
else process.env.PI_TEAMS_EXECUTE_WORKERS = prevExec;
|
|
110
112
|
if (prevMock === undefined) delete process.env.PI_TEAMS_MOCK_CHILD_PI;
|
|
111
113
|
else process.env.PI_TEAMS_MOCK_CHILD_PI = prevMock;
|
|
114
|
+
if (prevAllow === undefined) delete process.env.PI_CREW_ALLOW_MOCK;
|
|
115
|
+
else process.env.PI_CREW_ALLOW_MOCK = prevAllow;
|
|
112
116
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
113
117
|
}
|
|
114
118
|
});
|