pi-crew 0.11.43 → 0.11.45
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 +75 -1
- package/dist/index.mjs +501 -501
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +15 -6
- package/src/extension/session-summary.ts +32 -2
- package/src/extension/team-tool/health-monitor.ts +19 -6
- package/src/runtime/broker/crew-broker.ts +40 -33
- package/src/runtime/broker/socket-stop.ts +147 -0
- package/src/runtime/child-pi/child-pi-kill.ts +44 -0
- package/src/runtime/child-pi/inference-broker.ts +40 -8
- package/src/runtime/goal-workflow/goal-achievement.ts +10 -1
- package/src/runtime/pi-spawn.ts +10 -1
- package/src/runtime/process-status.ts +16 -0
- package/src/runtime/task-runner/post-execution.ts +36 -2
- package/src/ui/agents-jobs-browser.ts +2 -3
- package/src/ui/powerbar-publisher.ts +6 -2
- package/src/ui/run-dashboard.ts +1 -19
- package/src/ui/widget/widget-model.ts +49 -36
- package/src/utils/socket-path.ts +54 -0
- package/src/worktree/branch-freshness.ts +9 -22
- package/src/worktree/cleanup.ts +78 -65
- package/src/worktree/git-hardening.ts +507 -0
- package/src/worktree/worktree-manager.ts +176 -129
|
@@ -30,6 +30,8 @@ import type {
|
|
|
30
30
|
UsageState,
|
|
31
31
|
VerificationEvidence,
|
|
32
32
|
} from "../../state/types.ts";
|
|
33
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
34
|
+
import { buildGitSafeEnv, hardenGitArgs, worktreeTrustCheck } from "../../worktree/git-hardening.ts";
|
|
33
35
|
import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync } from "../../worktree/worktree-manager.ts";
|
|
34
36
|
import { appendTaskAttentionEvent } from "../attention-events.ts";
|
|
35
37
|
import { extractCommandTrace } from "../command-trace.ts";
|
|
@@ -150,16 +152,48 @@ const DRIFT_BLOCK_MARKER = /DRIFT-BLOCK\b/i;
|
|
|
150
152
|
* READ-ONLY lanes — any entry (modified OR untracked) is an unexpected
|
|
151
153
|
* mutation. Returns undefined when the tree is clean or git fails (fail-open:
|
|
152
154
|
* a broken probe must not break task finalization). Note-only, never a fail.
|
|
155
|
+
* R-A2 exception: a FAILED GITDIR-POINTER GUARD is not a broken probe but a
|
|
156
|
+
* live corruption signal — it reports a mutation note (see body).
|
|
153
157
|
*/
|
|
154
158
|
export async function detectReadLaneMutation(worktreePath: string): Promise<NonNullable<TeamTaskState["unexpectedMutation"]> | undefined> {
|
|
155
159
|
try {
|
|
160
|
+
// R-A2 (MED-1 follow-up): this probe used to be a raw execFile with the
|
|
161
|
+
// FULL process.env — API keys could leak to worker-planted fsmonitor /
|
|
162
|
+
// hook config reachable from the probed worktree. It now routes through
|
|
163
|
+
// the MED-1 hardening: hardenGitArgs config-injection overrides, the
|
|
164
|
+
// sanitized git env (no process.env inheritance), and the gitdir-pointer
|
|
165
|
+
// guard. Guard failure is fail-safe TOWARD REPORTING a mutation: a
|
|
166
|
+
// corrupted `.git` pointer in a READ-ONLY lane is itself a lane
|
|
167
|
+
// violation (the worker rewrote something it must not touch), so it
|
|
168
|
+
// surfaces as a note + internal log instead of a clean verdict.
|
|
169
|
+
const trust = worktreeTrustCheck(worktreePath);
|
|
170
|
+
if (trust.kind === "worktree" && !trust.ok) {
|
|
171
|
+
logInternalError(
|
|
172
|
+
"post-execution.readLane.gitdirMismatch",
|
|
173
|
+
new Error(`Refusing to run the mutation probe inside ${worktreePath}: ${trust.reason}`),
|
|
174
|
+
`worktreePath=${worktreePath}`,
|
|
175
|
+
);
|
|
176
|
+
return {
|
|
177
|
+
filesChanged: 0,
|
|
178
|
+
sample: [`worktree corrupted — gitdir pointer check failed (${trust.reason}); no git executed`],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
156
181
|
// W10-8 one-shot git probe per read-only task — child_process is
|
|
157
182
|
// already in the platform; dynamic keeps it off the static import graph.
|
|
158
183
|
// LAZY: see above
|
|
159
184
|
const { execFile } = await import("node:child_process");
|
|
160
185
|
const out: string = await new Promise((resolve, reject) => {
|
|
161
|
-
execFile(
|
|
162
|
-
|
|
186
|
+
execFile(
|
|
187
|
+
"git",
|
|
188
|
+
hardenGitArgs(["status", "--porcelain"]),
|
|
189
|
+
{
|
|
190
|
+
cwd: worktreePath,
|
|
191
|
+
env: buildGitSafeEnv(),
|
|
192
|
+
encoding: "utf-8",
|
|
193
|
+
timeout: 10_000,
|
|
194
|
+
windowsHide: true,
|
|
195
|
+
},
|
|
196
|
+
(err, stdout) => (err ? reject(err) : resolve(stdout)),
|
|
163
197
|
);
|
|
164
198
|
});
|
|
165
199
|
const entries = out
|
|
@@ -60,7 +60,7 @@ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
|
60
60
|
import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
|
|
61
61
|
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
62
62
|
import { createManifestCache } from "../runtime/manifest-cache.ts";
|
|
63
|
-
import {
|
|
63
|
+
import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
64
64
|
import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
|
|
65
65
|
import { surfaceGateEnvSnapshot } from "../runtime/surface/resolve-surface.ts";
|
|
66
66
|
import { getTaskUsage } from "../runtime/usage-tracker.ts";
|
|
@@ -504,8 +504,7 @@ export class AgentsJobsBrowser {
|
|
|
504
504
|
// heartbeats are fresh (this loop already does per-run I/O — one more
|
|
505
505
|
// coalesced read is in-pattern). Terminal/async runs are never
|
|
506
506
|
// resurrected (isActiveRunStatus && !run.async).
|
|
507
|
-
const heartbeatLive =
|
|
508
|
-
isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(readLegacyRunTasks(run), nowMs);
|
|
507
|
+
const heartbeatLive = heartbeatLiveForDisplay(run, readLegacyRunTasks(run), nowMs);
|
|
509
508
|
if (isDisplayActiveRun(run, agents, nowMs) || heartbeatLive) runs.push({ run, agents });
|
|
510
509
|
} catch {
|
|
511
510
|
/* unreadable — skip */
|
|
@@ -5,7 +5,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
|
5
5
|
import { listLiveAgents, listLiveAgentsByWorkspace } from "../runtime/live-session/live-agent-manager.ts";
|
|
6
6
|
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
7
7
|
import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
|
|
8
|
-
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
8
|
+
import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
9
9
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
10
10
|
import { aggregateUsage } from "../state/usage.ts";
|
|
11
11
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
@@ -307,7 +307,11 @@ class PowerbarPublisher {
|
|
|
307
307
|
}
|
|
308
308
|
return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
|
|
309
309
|
})
|
|
310
|
-
|
|
310
|
+
// Wave 16 (residual render wiring): item.tasks is already in scope
|
|
311
|
+
// (snapshot.tasks | readTasks fallback) — OR-ing the heartbeat guard in
|
|
312
|
+
// adds ZERO extra disk reads; a frozen-manifest run stays in the
|
|
313
|
+
// powerbar while its task heartbeats stay fresh.
|
|
314
|
+
.filter((item) => isDisplayActiveRun(item.run, item.agents) || heartbeatLiveForDisplay(item.run, item.tasks));
|
|
311
315
|
if (!active.length) {
|
|
312
316
|
this.#lastActiveKey = undefined;
|
|
313
317
|
this.#lastProgressKey = undefined;
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -10,12 +10,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
|
10
10
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
11
11
|
import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts";
|
|
12
12
|
import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
|
|
13
|
-
import {
|
|
14
|
-
hasFreshTaskHeartbeatEvidence,
|
|
15
|
-
isActiveRunStatus,
|
|
16
|
-
isDisplayActiveRun,
|
|
17
|
-
isLikelyOrphanedActiveRun,
|
|
18
|
-
} from "../runtime/process-status.ts";
|
|
13
|
+
import { heartbeatLiveForDisplay, isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
|
19
14
|
import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
|
|
20
15
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
21
16
|
import { aggregateUsage } from "../state/usage.ts";
|
|
@@ -368,19 +363,6 @@ function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskS
|
|
|
368
363
|
return tasks.find((task) => task.id === agent.taskId);
|
|
369
364
|
}
|
|
370
365
|
|
|
371
|
-
/**
|
|
372
|
-
* Wave 15 (F2, GAP-0): tight heartbeat-liveness guard for ACTIVE display —
|
|
373
|
-
* mirrors the orphan predicate's own preconditions (active status, non-async)
|
|
374
|
-
* so a terminal or async run is never resurrected by a heartbeat. A healthy
|
|
375
|
-
* direct run mid-long-tool freezes manifest `updatedAt` and agent progress
|
|
376
|
-
* (event-driven writes) but keeps stamping `task.heartbeat.lastSeenAt`, so
|
|
377
|
-
* fresh evidence means the run is alive. Call sites OR this in ONLY — every
|
|
378
|
-
* non-stale branch and layout stays untouched.
|
|
379
|
-
*/
|
|
380
|
-
function heartbeatLiveForDisplay(run: TeamRunManifest, tasks: TeamTaskState[] | undefined, now = Date.now()): boolean {
|
|
381
|
-
return isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(tasks, now);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
366
|
function modelForTask(task: TeamTaskState | undefined): string | undefined {
|
|
385
367
|
const attempts = task?.modelAttempts;
|
|
386
368
|
if (!attempts?.length) return undefined;
|
|
@@ -9,7 +9,7 @@ import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
|
9
9
|
import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
10
10
|
import { evictStaleLiveAgentHandles } from "../../runtime/live-session/live-agent-manager.ts";
|
|
11
11
|
import type { ManifestCache } from "../../runtime/manifest-cache.ts";
|
|
12
|
-
import { isDisplayActiveRun } from "../../runtime/process-status.ts";
|
|
12
|
+
import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../../runtime/process-status.ts";
|
|
13
13
|
import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts";
|
|
14
14
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
15
15
|
import { goalFlagSuffix } from "../goal-flag.ts";
|
|
@@ -61,42 +61,55 @@ export function activeWidgetRuns(
|
|
|
61
61
|
// workspaceId stays in scope: crash recovery below is still session-scoped.
|
|
62
62
|
const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
|
|
63
63
|
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
return (
|
|
65
|
+
runs
|
|
66
|
+
.map((run) => {
|
|
67
|
+
try {
|
|
68
|
+
// U7 (spec 2026-10-09): prefer the committed-state read when the
|
|
69
|
+
// cache exposes it. `readForRender` returns the SAME cached
|
|
70
|
+
// snapshot `get` would (identical render — parity), but a lapsed
|
|
71
|
+
// TTL schedules the coalesced ASYNC refresh (80ms batch → stamp
|
|
72
|
+
// check → rebuild only when files changed) instead of waiting for
|
|
73
|
+
// an external poller to notice. Structural check: legacy callers
|
|
74
|
+
// and test doubles may implement only `get`.
|
|
75
|
+
const committed = snapshotCache as
|
|
76
|
+
| {
|
|
77
|
+
readForRender?: (runId: string) => RunUiSnapshot | undefined;
|
|
78
|
+
get?: (runId: string) => RunUiSnapshot | undefined;
|
|
79
|
+
}
|
|
80
|
+
| undefined;
|
|
81
|
+
const snapshot =
|
|
82
|
+
typeof committed?.readForRender === "function" ? committed.readForRender(run.runId) : snapshotCache?.get(run.runId);
|
|
83
|
+
if (snapshot) {
|
|
84
|
+
return {
|
|
85
|
+
run: snapshot.manifest,
|
|
86
|
+
agents: snapshot.agents,
|
|
87
|
+
snapshot,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// P0-6: render from snapshots only — never read disk on every
|
|
91
|
+
// render tick. When the snapshot cache is provided but hasn't
|
|
92
|
+
// populated yet (first frame after `updateCrewWidget`), drop the
|
|
93
|
+
// run from the result so the widget paints "(loading…)" instead
|
|
94
|
+
// of calling `agentsFor(run) → readCrewAgents`. Legacy callers
|
|
95
|
+
// (no snapshotCache) keep the disk-read fallback for tests/dev.
|
|
96
|
+
if (snapshotCache) return null;
|
|
97
|
+
return { run, agents: agentsFor(run) };
|
|
98
|
+
} catch {
|
|
99
|
+
if (snapshotCache) return null;
|
|
100
|
+
return { run, agents: agentsFor(run) };
|
|
85
101
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
})
|
|
99
|
-
.filter((item): item is WidgetRun => item !== null && isDisplayActiveRun(item.run, item.agents));
|
|
102
|
+
})
|
|
103
|
+
// Wave 16 (residual render wiring): the dock's high-frequency tick NEVER
|
|
104
|
+
// gains a disk read here — only the snapshot the render path already
|
|
105
|
+
// holds. OR-in ONLY: a run hidden because its manifest froze mid-long-tool
|
|
106
|
+
// stays visible while its task heartbeats are fresh (GAP-4 mix-A).
|
|
107
|
+
.filter(
|
|
108
|
+
(item): item is WidgetRun =>
|
|
109
|
+
item !== null &&
|
|
110
|
+
(isDisplayActiveRun(item.run, item.agents, now) || heartbeatLiveForDisplay(item.run, item.snapshot?.tasks, now)),
|
|
111
|
+
)
|
|
112
|
+
);
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
/**
|
package/src/utils/socket-path.ts
CHANGED
|
@@ -122,11 +122,62 @@ export async function prepareBrokerSocketDir(sockPath: string): Promise<void> {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** Literal-escaping helper for building filesystem-name regexes. */
|
|
126
|
+
function escapeRegExp(literal: string): string {
|
|
127
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* B-LOW1: sweep `${sockPath}.stopping.<pid>.<hex>` shield corpses left by
|
|
132
|
+
* broker/socket-stop.ts. stopServerAndRemoveSocket renames a foreign/unpinned
|
|
133
|
+
* socket leaf aside across the libuv close-unlink window and renames it back
|
|
134
|
+
* afterwards; a crash between those renames strands the shield forever — the
|
|
135
|
+
* plain removeStaleBrokerSocket flow only ever looks at `sockPath` itself, so
|
|
136
|
+
* the corpses previously accumulated one per crashed stop.
|
|
137
|
+
*
|
|
138
|
+
* A corpse is removed ONLY when the pid embedded in its name is provably dead
|
|
139
|
+
* (process.kill(pid, 0) → ESRCH). EPERM/any other signal result means the pid
|
|
140
|
+
* is alive (or its liveness cannot be proven) — the shield is kept (fail-safe:
|
|
141
|
+
* a live broker may still restore it). Best-effort: never throws.
|
|
142
|
+
*/
|
|
143
|
+
async function sweepStoppingShields(sockPath: string): Promise<void> {
|
|
144
|
+
if (process.platform === "win32") return; // named pipes have no fs leaves
|
|
145
|
+
const dir = path.dirname(sockPath);
|
|
146
|
+
const pattern = new RegExp(`^${escapeRegExp(path.basename(sockPath))}\\.stopping\\.(\\d+)\\.[0-9a-f]{1,32}$`);
|
|
147
|
+
let entries: string[];
|
|
148
|
+
try {
|
|
149
|
+
entries = await fsp.readdir(dir);
|
|
150
|
+
} catch {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
const match = pattern.exec(entry);
|
|
155
|
+
if (!match) continue;
|
|
156
|
+
const pid = Number.parseInt(match[1]!, 10);
|
|
157
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
continue; // pid alive — keep the shield
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
163
|
+
if (code !== "ESRCH") continue; // cannot prove dead — keep
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await fsp.unlink(path.join(dir, entry));
|
|
167
|
+
} catch {
|
|
168
|
+
/* best-effort */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
125
173
|
/** Connect-then-unlink stale socket (herdr pattern). If a live broker is
|
|
126
174
|
* listening, leave the endpoint intact (EADDRINUSE will surface on bind).
|
|
127
175
|
* If a stale file exists with no listener, remove it. If the path is a
|
|
128
176
|
* symlink, refuse rather than follow.
|
|
129
177
|
*
|
|
178
|
+
* Also sweeps `${sockPath}.stopping.*` shield corpses whose recorded pid is
|
|
179
|
+
* dead (B-LOW1 — see sweepStoppingShields).
|
|
180
|
+
*
|
|
130
181
|
* Returns "removed" when the stale entry was unlinked, "kept" when a live
|
|
131
182
|
* listener was detected, "absent" when no entry existed, "refused"
|
|
132
183
|
* when the entry is a symlink. */
|
|
@@ -138,6 +189,9 @@ export async function removeStaleBrokerSocket(
|
|
|
138
189
|
// the timeout branch without racing a real localhost connect.
|
|
139
190
|
netModule?: { createConnection: (path: string) => { once: (ev: string, fn: (arg?: unknown) => void) => void; destroy: () => void } },
|
|
140
191
|
): Promise<"removed" | "kept" | "absent" | "refused"> {
|
|
192
|
+
// B-LOW1: sweep stranded stop-shields first (best-effort, never affects the
|
|
193
|
+
// sockPath verdict below).
|
|
194
|
+
await sweepStoppingShields(sockPath);
|
|
141
195
|
// Reject symlinks outright.
|
|
142
196
|
let st: Awaited<ReturnType<typeof fsp.lstat>>;
|
|
143
197
|
try {
|
|
@@ -1,25 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
2
|
+
import { buildGitSafeEnv, hardenGitArgs } from "../worktree/git-hardening.ts";
|
|
4
3
|
|
|
5
|
-
// Read-only git operations only (rev-list, rev-parse, log).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"HOME",
|
|
12
|
-
"USER",
|
|
13
|
-
...WINDOWS_ESSENTIAL_ENV_VARS,
|
|
14
|
-
"SHELL",
|
|
15
|
-
"TERM",
|
|
16
|
-
"LANG",
|
|
17
|
-
"LC_ALL",
|
|
18
|
-
"GIT_CONFIG_GLOBAL",
|
|
19
|
-
"GIT_CONFIG_SYSTEM",
|
|
20
|
-
"GIT_EXEC_PATH",
|
|
21
|
-
],
|
|
22
|
-
});
|
|
4
|
+
// Read-only git operations only (rev-list, rev-parse, log). C-1 fix
|
|
5
|
+
// (code-review 2026-06-23): never inherit the full parent env — API keys
|
|
6
|
+
// must not reach git hooks/aliases/credential-helpers. R-A6 (MED-1
|
|
7
|
+
// uniformity): env + argv now come from the shared git-hardening helpers
|
|
8
|
+
// (sanitizeEnvSecrets allowList, GIT_CONFIG_NOSYSTEM=1, config-injection
|
|
9
|
+
// overrides) instead of a drifting local GIT_SAFE_ENV copy.
|
|
23
10
|
|
|
24
11
|
export type BranchFreshnessStatus = "fresh" | "stale" | "diverged" | "unknown";
|
|
25
12
|
export type StaleBranchPolicy = "warn" | "block" | "auto_rebase" | "auto_merge_forward";
|
|
@@ -36,11 +23,11 @@ export interface BranchFreshness {
|
|
|
36
23
|
}
|
|
37
24
|
|
|
38
25
|
function git(cwd: string, args: string[]): string {
|
|
39
|
-
return execFileSync("git", args, {
|
|
26
|
+
return execFileSync("git", hardenGitArgs(args), {
|
|
40
27
|
cwd,
|
|
28
|
+
env: buildGitSafeEnv(),
|
|
41
29
|
encoding: "utf-8",
|
|
42
30
|
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
-
env: GIT_SAFE_ENV,
|
|
44
31
|
windowsHide: true,
|
|
45
32
|
}).trim();
|
|
46
33
|
}
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -4,10 +4,15 @@ import * as path from "node:path";
|
|
|
4
4
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
5
5
|
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
6
6
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
|
-
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
|
|
8
|
-
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
9
7
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
10
8
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
9
|
+
import {
|
|
10
|
+
assertWorktreePointerTrusted,
|
|
11
|
+
buildGitSafeEnv,
|
|
12
|
+
hardenGitArgs,
|
|
13
|
+
resolveTrustedGitDir,
|
|
14
|
+
verifyWorktreeGitdirPointer,
|
|
15
|
+
} from "./git-hardening.ts";
|
|
11
16
|
|
|
12
17
|
export interface WorktreeCleanupResult {
|
|
13
18
|
removed: string[];
|
|
@@ -17,39 +22,16 @@ export interface WorktreeCleanupResult {
|
|
|
17
22
|
committedBranches: string[];
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"TERM",
|
|
31
|
-
"LANG",
|
|
32
|
-
"LC_ALL",
|
|
33
|
-
"LC_COLLATE",
|
|
34
|
-
"LC_CTYPE",
|
|
35
|
-
"LC_MESSAGES",
|
|
36
|
-
"XDG_CONFIG_HOME",
|
|
37
|
-
"XDG_DATA_HOME",
|
|
38
|
-
"XDG_CACHE_HOME",
|
|
39
|
-
"NVM_BIN",
|
|
40
|
-
"NVM_DIR",
|
|
41
|
-
"NODE_PATH",
|
|
42
|
-
"GIT_CONFIG_GLOBAL",
|
|
43
|
-
"GIT_CONFIG_SYSTEM",
|
|
44
|
-
"GIT_AUTHOR_NAME",
|
|
45
|
-
"GIT_AUTHOR_EMAIL",
|
|
46
|
-
"GIT_COMMITTER_NAME",
|
|
47
|
-
"GIT_COMMITTER_EMAIL",
|
|
48
|
-
],
|
|
49
|
-
}),
|
|
50
|
-
LANG: "C",
|
|
51
|
-
LC_ALL: "C",
|
|
52
|
-
};
|
|
25
|
+
// MED-1 / R-A4: shared sanitized env for every git spawn (see
|
|
26
|
+
// buildGitSafeEnv in git-hardening.ts — allowList-based secret stripping,
|
|
27
|
+
// GIT_CONFIG_NOSYSTEM=1, GIT_CONFIG_SYSTEM dropped from the allowlist,
|
|
28
|
+
// GIT_CONFIG_GLOBAL kept for commit identity with the documented residual
|
|
29
|
+
// that trusted-config filter definitions remain executable).
|
|
30
|
+
// Built per call (not module-load) so late env changes (tests, config reload)
|
|
31
|
+
// are honored.
|
|
32
|
+
function gitSafeEnv(): Record<string, string> {
|
|
33
|
+
return buildGitSafeEnv();
|
|
34
|
+
}
|
|
53
35
|
|
|
54
36
|
function sanitizeBranchPart(value: string): string {
|
|
55
37
|
return (
|
|
@@ -66,12 +48,20 @@ function sanitizeFilename(value: string): string {
|
|
|
66
48
|
}
|
|
67
49
|
|
|
68
50
|
function git(cwd: string, args: string[]): string {
|
|
51
|
+
// R-A3: re-verify the gitdir pointer before EVERY command whose cwd sits
|
|
52
|
+
// inside a linked worktree (TOCTOU: the entry-level guard in
|
|
53
|
+
// cleanupRunWorktrees can be invalidated by a still-live worker flipping
|
|
54
|
+
// the `.git` pointer mid-chain). Throws OUTSIDE the execFile try so the
|
|
55
|
+
// error propagates unwrapped into the existing "corrupted" handling paths.
|
|
56
|
+
assertWorktreePointerTrusted(cwd);
|
|
69
57
|
try {
|
|
70
|
-
|
|
58
|
+
// MED-1: harden EVERY git invocation (config-injection overrides +
|
|
59
|
+
// diff/commit neutralizers). See src/worktree/git-hardening.ts.
|
|
60
|
+
return execFileSync("git", hardenGitArgs(args), {
|
|
71
61
|
cwd,
|
|
62
|
+
env: gitSafeEnv(),
|
|
72
63
|
encoding: "utf-8",
|
|
73
64
|
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
-
env: GIT_SAFE_ENV,
|
|
75
65
|
windowsHide: true,
|
|
76
66
|
}).trim();
|
|
77
67
|
} catch (error) {
|
|
@@ -116,6 +106,9 @@ export function cleanupRunWorktrees(
|
|
|
116
106
|
committedBranches: [],
|
|
117
107
|
};
|
|
118
108
|
if (!fs.existsSync(worktreeRoot)) return result;
|
|
109
|
+
// MED-1: resolve the TRUSTED git common dir once (from the orchestrator-owned
|
|
110
|
+
// main repo — never from inside a worktree) for the per-entry gitdir guard.
|
|
111
|
+
const trustedGitDir = resolveTrustedGitDir(manifest.cwd);
|
|
119
112
|
|
|
120
113
|
// M3 fix: use withFileTypes to avoid race between readdirSync and statSync.
|
|
121
114
|
// Rely on Dirent.isDirectory() instead of a separate statSync to eliminate TOCTOU window.
|
|
@@ -127,6 +120,46 @@ export function cleanupRunWorktrees(
|
|
|
127
120
|
if (options.signal?.aborted) break;
|
|
128
121
|
const worktreePath = path.join(worktreeRoot, entry.name);
|
|
129
122
|
if (options.signal?.aborted) break;
|
|
123
|
+
// MED-1 gitdir-hijack guard: a worker can rewrite the worktree `.git`
|
|
124
|
+
// pointer file to an attacker-controlled directory whose poisoned `config`
|
|
125
|
+
// would execute hooks/filters/fsmonitor in the orchestrator context. Before
|
|
126
|
+
// ANY git runs with cwd inside the worktree, the pointer must resolve to the
|
|
127
|
+
// git-registered admin dir (see src/worktree/git-hardening.ts). On mismatch
|
|
128
|
+
// NO git runs from inside; removal is only attempted (force) from the
|
|
129
|
+
// trusted main-repo cwd, and the worktree is otherwise preserved.
|
|
130
|
+
const gitdirCheck = trustedGitDir
|
|
131
|
+
? verifyWorktreeGitdirPointer(worktreePath, trustedGitDir)
|
|
132
|
+
: { ok: false, reason: `main repository git dir not found under ${manifest.cwd}` };
|
|
133
|
+
if (!gitdirCheck.ok) {
|
|
134
|
+
logInternalError(
|
|
135
|
+
"cleanup.worktree.gitdirMismatch",
|
|
136
|
+
new Error(`Refusing to run git inside ${worktreePath}: ${gitdirCheck.reason}`),
|
|
137
|
+
`runId=${manifest.runId}`,
|
|
138
|
+
);
|
|
139
|
+
if (options.force) {
|
|
140
|
+
let removed = false;
|
|
141
|
+
try {
|
|
142
|
+
git(manifest.cwd, ["worktree", "remove", "--force", worktreePath]);
|
|
143
|
+
removed = true;
|
|
144
|
+
} catch {
|
|
145
|
+
try {
|
|
146
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
147
|
+
removed = true;
|
|
148
|
+
} catch {
|
|
149
|
+
/* keep as preserved */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (removed) {
|
|
153
|
+
result.removed.push(worktreePath);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
result.preserved.push({
|
|
158
|
+
path: worktreePath,
|
|
159
|
+
reason: `worktree corrupted — gitdir pointer check failed (${gitdirCheck.reason}); no git executed inside; inspect manually or remove with force=true`,
|
|
160
|
+
});
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
130
163
|
const dirty = isDirty(worktreePath);
|
|
131
164
|
const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
|
|
132
165
|
const safeBranchName = sanitizeBranchPart(entry.name);
|
|
@@ -156,13 +189,9 @@ export function cleanupRunWorktrees(
|
|
|
156
189
|
try {
|
|
157
190
|
// Issue 2 fix: verify status before and after add to ensure atomicity
|
|
158
191
|
const statusBefore = git(worktreePath, ["status", "--porcelain"]);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
163
|
-
env: GIT_SAFE_ENV,
|
|
164
|
-
windowsHide: true,
|
|
165
|
-
});
|
|
192
|
+
// MED-1: routed through the hardened git() helper (fsmonitor/hooksPath
|
|
193
|
+
// disabled) — a worker-planted config must not execute on `git add`.
|
|
194
|
+
git(worktreePath, ["add", "-A"]);
|
|
166
195
|
// Verify no unexpected changes were added (only staged the original dirty files)
|
|
167
196
|
const statusAfterAdd = git(worktreePath, ["status", "--porcelain"]);
|
|
168
197
|
if (statusAfterAdd !== statusBefore) {
|
|
@@ -177,37 +206,21 @@ export function cleanupRunWorktrees(
|
|
|
177
206
|
if (safeDesc.includes("\n")) {
|
|
178
207
|
safeDesc = safeDesc.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, " ");
|
|
179
208
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
184
|
-
env: GIT_SAFE_ENV,
|
|
185
|
-
windowsHide: true,
|
|
186
|
-
});
|
|
209
|
+
// MED-1: --no-verify + hooksPath= neutralize every commit hook
|
|
210
|
+
// (pre-commit/commit-msg/prepare-commit-msg/post-commit).
|
|
211
|
+
git(worktreePath, ["commit", "-m", `pi-crew: ${safeDesc}`]);
|
|
187
212
|
// Issue 1 fix: check signal before branch creation
|
|
188
213
|
if (options.signal?.aborted) break;
|
|
189
214
|
// Create branch in the main repo pointing to this worktree's HEAD
|
|
190
215
|
let branchError: Error | null = null;
|
|
191
216
|
try {
|
|
192
|
-
|
|
193
|
-
cwd: worktreePath,
|
|
194
|
-
encoding: "utf-8",
|
|
195
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
196
|
-
env: GIT_SAFE_ENV,
|
|
197
|
-
windowsHide: true,
|
|
198
|
-
});
|
|
217
|
+
git(worktreePath, ["branch", branchName]);
|
|
199
218
|
} catch (err) {
|
|
200
219
|
branchError = err instanceof Error ? err : new Error(String(err));
|
|
201
220
|
// Branch already exists — use timestamp suffix
|
|
202
221
|
const tsBranch = `${branchName}-${Date.now()}`;
|
|
203
222
|
try {
|
|
204
|
-
|
|
205
|
-
cwd: worktreePath,
|
|
206
|
-
encoding: "utf-8",
|
|
207
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
208
|
-
env: GIT_SAFE_ENV,
|
|
209
|
-
windowsHide: true,
|
|
210
|
-
});
|
|
223
|
+
git(worktreePath, ["branch", tsBranch]);
|
|
211
224
|
} catch (err2) {
|
|
212
225
|
// Both branch attempts failed — accumulate error for outer catch
|
|
213
226
|
const err2_msg = err2 instanceof Error ? err2.message : String(err2);
|