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
package/package.json
CHANGED
|
@@ -165,12 +165,21 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => un
|
|
|
165
165
|
// pi-crew:rpc:live-control, and the realtime pi-crew:live-control channel) are
|
|
166
166
|
// accessible to any extension on the shared event bus. Mitigations: rate
|
|
167
167
|
// limiting, explicit intent, operation allowlist, cwd containment validation,
|
|
168
|
-
// and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9)
|
|
169
|
-
// valid HMAC signature
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
168
|
+
// and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9) — what a
|
|
169
|
+
// publisher WITHOUT a valid HMAC signature may do:
|
|
170
|
+
// - pi-crew:rpc:run is the ONLY mutating channel that accepts unsigned
|
|
171
|
+
// requests. This is deliberate (SEC-03, test-pinned backward-compat):
|
|
172
|
+
// each call is rate-limited (RPC_RATE_LIMIT_MAX requests per
|
|
173
|
+
// RPC_RATE_LIMIT_WINDOW_MS — 5/60s), the payload is filtered to the
|
|
174
|
+
// ALLOWED_RPC_RUN_KEYS allowlist (unknown keys are stripped before
|
|
175
|
+
// handleTeamTool ever sees them), and config.cwd must pass containment
|
|
176
|
+
// validation (inside the project directory).
|
|
177
|
+
// - The remaining request/response channels are read-only for unsigned
|
|
178
|
+
// publishers, enforced via RPC_ALLOWED_OPERATIONS.
|
|
179
|
+
// - The realtime pi-crew:live-control channel rejects ALL unsigned
|
|
180
|
+
// operations — LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS is the empty set
|
|
181
|
+
// (steer/stop/resume/follow-up are all mutating) — with a warn log and no
|
|
182
|
+
// publish.
|
|
174
183
|
|
|
175
184
|
export function registerPiCrewRpc(
|
|
176
185
|
events: EventBusLike | undefined,
|
|
@@ -1,9 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
3
|
-
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
4
|
+
import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
5
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
|
+
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
4
7
|
import { extractSessionId } from "../utils/session-utils.ts";
|
|
5
8
|
import { listRuns } from "./run-index.ts";
|
|
6
9
|
|
|
10
|
+
/** Tasks-read TTL for the one-shot toast's heartbeat check (coalesced, live-run-sidebar pattern). */
|
|
11
|
+
const SUMMARY_TASKS_READ_TTL_MS = 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wave 16 (residual render wiring, GAP-4): coalesced tasks.json read for the
|
|
15
|
+
* active-runs toast — clones agents-jobs-browser's readLegacyRunTasks. Returns
|
|
16
|
+
* undefined on unreadable so a broken tasks.json can NEVER fabricate liveness
|
|
17
|
+
* evidence (fail-closed).
|
|
18
|
+
*/
|
|
19
|
+
function readSummaryRunTasks(run: TeamRunManifest): TeamTaskState[] | undefined {
|
|
20
|
+
const parse = (): TeamTaskState[] => {
|
|
21
|
+
if (!existsSync(run.tasksPath)) return [];
|
|
22
|
+
const parsed = JSON.parse(readFileSync(run.tasksPath, "utf-8")) as unknown;
|
|
23
|
+
return Array.isArray(parsed) ? (parsed as TeamTaskState[]) : [];
|
|
24
|
+
};
|
|
25
|
+
try {
|
|
26
|
+
return readJsonFileCoalesced(run.tasksPath, SUMMARY_TASKS_READ_TTL_MS, parse);
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
export function notifyActiveRuns(ctx: ExtensionContext): void {
|
|
8
33
|
const sid = extractSessionId(ctx);
|
|
9
34
|
const active = listRuns(ctx.cwd)
|
|
@@ -15,7 +40,12 @@ export function notifyActiveRuns(ctx: ExtensionContext): void {
|
|
|
15
40
|
// Use the same display filter as the widget/powerbar — runs without
|
|
16
41
|
// real agent evidence (e.g. integration test fixtures) must not appear.
|
|
17
42
|
const agents = readCrewAgents(run);
|
|
18
|
-
|
|
43
|
+
if (isDisplayActiveRun(run, agents)) return true;
|
|
44
|
+
// Wave 16: this is a ONE-SHOT toast, so the lazy coalesced tasks read is
|
|
45
|
+
// in-pattern I/O — fetched ONLY when the display predicate is about to
|
|
46
|
+
// call the run inactive, so a manifest frozen mid-long-tool (fresh task
|
|
47
|
+
// heartbeats, no agent progress) still gets advertised.
|
|
48
|
+
return heartbeatLiveForDisplay(run, readSummaryRunTasks(run));
|
|
19
49
|
})
|
|
20
50
|
.slice(0, 5);
|
|
21
51
|
if (active.length === 0) return;
|
|
@@ -5,7 +5,12 @@ import * as fs from "node:fs";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
hasFreshTaskHeartbeatEvidence,
|
|
10
|
+
hasStaleAsyncProcess,
|
|
11
|
+
isActiveRunStatus,
|
|
12
|
+
isLikelyOrphanedActiveRun,
|
|
13
|
+
} from "../../runtime/process-status.ts";
|
|
9
14
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
10
15
|
import { loadTasksWithRecovery } from "../../state/stores/state-store.ts";
|
|
11
16
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
@@ -374,10 +379,15 @@ export function buildHealthReport(
|
|
|
374
379
|
continue;
|
|
375
380
|
}
|
|
376
381
|
|
|
377
|
-
// 3. Stuck task detection for active runs
|
|
382
|
+
// 3. Stuck task detection for active runs. Wave 16 (residual render
|
|
383
|
+
// wiring): the tasks loaded here are HOISTED — step 6 reuses them so the
|
|
384
|
+
// orphan guard consults fresh task heartbeats with ZERO extra disk reads
|
|
385
|
+
// (parity with run-dashboard's stale predicate).
|
|
386
|
+
let tasks: TeamTaskState[] | undefined;
|
|
378
387
|
if (isActiveRunStatus(run.status)) {
|
|
379
|
-
const
|
|
380
|
-
|
|
388
|
+
const loaded = readRunTasks(run.stateRoot);
|
|
389
|
+
tasks = loaded;
|
|
390
|
+
allStuck.push(...stuckTasksFromRecords(loaded, run.runId, now));
|
|
381
391
|
}
|
|
382
392
|
|
|
383
393
|
// 4. Only check orphan status for active runs
|
|
@@ -393,9 +403,12 @@ export function buildHealthReport(
|
|
|
393
403
|
continue;
|
|
394
404
|
}
|
|
395
405
|
|
|
396
|
-
// 6. Orphaned: non-async active run with no recent update
|
|
406
|
+
// 6. Orphaned: non-async active run with no recent update. Wave 16: a
|
|
407
|
+
// run whose manifest froze mid-long-tool (worker busy, event-driven
|
|
408
|
+
// writes paused) keeps stamping task heartbeats — fresh evidence
|
|
409
|
+
// suppresses the stale-no-progress label (run-dashboard parity).
|
|
397
410
|
const agents = readCrewAgents(run as TeamRunManifest);
|
|
398
|
-
if (isLikelyOrphanedActiveRun(run as TeamRunManifest, agents, now)) {
|
|
411
|
+
if (isLikelyOrphanedActiveRun(run as TeamRunManifest, agents, now) && !hasFreshTaskHeartbeatEvidence(tasks, now)) {
|
|
399
412
|
orphaned.push({
|
|
400
413
|
runId: run.runId,
|
|
401
414
|
reason: "stale-no-progress",
|
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
} from "./protocol/request-parsers.ts";
|
|
91
91
|
import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
|
|
92
92
|
import { handleWaitResolve } from "./protocol/wait-resolve.ts";
|
|
93
|
+
import { captureSocketIdentity, type SocketIdentity, stopServerAndRemoveSocket } from "./socket-stop.ts";
|
|
93
94
|
import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
|
|
94
95
|
import { WaitStatusCache } from "./wait-status-cache.ts";
|
|
95
96
|
|
|
@@ -144,6 +145,10 @@ export class CrewBroker {
|
|
|
144
145
|
private readonly taskTokens = new Map<string, BrokerToken>();
|
|
145
146
|
private server: net.Server | null = null;
|
|
146
147
|
private resolvedSocketPath: string | null = null;
|
|
148
|
+
/** [3] stop-unlink race: dev+ino of the leaf we created, captured right
|
|
149
|
+
* after listen() succeeded. stop() removes the path only while this
|
|
150
|
+
* still matches (a rebound leaf is a new inode); see socket-stop.ts. */
|
|
151
|
+
private socketIdentity: SocketIdentity | null = null;
|
|
147
152
|
private stopped = false;
|
|
148
153
|
private startingPromise: Promise<void> | null = null;
|
|
149
154
|
private readonly connections = new Set<ServerConnection>();
|
|
@@ -350,6 +355,13 @@ export class CrewBroker {
|
|
|
350
355
|
}
|
|
351
356
|
});
|
|
352
357
|
|
|
358
|
+
// 4.5 [3] stop-unlink race: pin the fresh leaf's identity so stop()
|
|
359
|
+
// can prove it still owns the path before removing it (win32
|
|
360
|
+
// named pipes have no fs leaf — nothing to pin).
|
|
361
|
+
if (process.platform !== "win32") {
|
|
362
|
+
this.socketIdentity = await captureSocketIdentity(sockPath);
|
|
363
|
+
}
|
|
364
|
+
|
|
353
365
|
// 5. Tighten socket permissions on POSIX. Node's net module has no
|
|
354
366
|
// `mode` option (unlike `fs`), so we chmod after listen() succeeds.
|
|
355
367
|
if (process.platform !== "win32") {
|
|
@@ -432,43 +444,46 @@ export class CrewBroker {
|
|
|
432
444
|
}
|
|
433
445
|
this.connections.clear();
|
|
434
446
|
|
|
435
|
-
// 2. Close the server
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
447
|
+
// 2+4. Close the server AND remove the socket path in one
|
|
448
|
+
// ownership-gated step. [3] stop-unlink race: libuv unlinks the
|
|
449
|
+
// bind path BY NAME when the listening handle closes, so the
|
|
450
|
+
// unlink/shield decision must wrap the close itself — see
|
|
451
|
+
// broker/socket-stop.ts. We never touch any other path and
|
|
452
|
+
// never `process.kill` anything.
|
|
453
|
+
const srv = this.server;
|
|
454
|
+
this.server = null;
|
|
455
|
+
const sockPath = this.resolvedSocketPath ?? this.options.socketPath;
|
|
456
|
+
const closeServer = () =>
|
|
457
|
+
new Promise<void>((resolve) => {
|
|
439
458
|
if (!srv) return resolve();
|
|
440
459
|
srv.close(() => resolve());
|
|
441
460
|
// If the server is not currently listening, close() resolves
|
|
442
461
|
// synchronously — guard with a hard timeout for safety.
|
|
443
462
|
setTimeout(() => resolve(), 250).unref();
|
|
444
463
|
});
|
|
445
|
-
|
|
464
|
+
if (sockPath && process.platform !== "win32") {
|
|
465
|
+
const outcome = await stopServerAndRemoveSocket(sockPath, this.socketIdentity, closeServer);
|
|
466
|
+
if (outcome === "shielded-foreign" || outcome === "unshielded-foreign") {
|
|
467
|
+
// Path exists but is not provably ours (rebound or never bound):
|
|
468
|
+
// we did not remove it — any corpse is left for
|
|
469
|
+
// removeStaleBrokerSocket at the next start (fail-safe design).
|
|
470
|
+
logInternalError(
|
|
471
|
+
"crew-broker.stop.unlink-skipped",
|
|
472
|
+
new Error(`socket path not provably owned (${outcome}); left for removeStaleBrokerSocket`),
|
|
473
|
+
`path=${redactSecretString(sockPath)}`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
await closeServer();
|
|
446
478
|
}
|
|
479
|
+
this.resolvedSocketPath = null;
|
|
480
|
+
this.socketIdentity = null;
|
|
447
481
|
|
|
448
482
|
// 3. Clear the token map. This is the single point where the heap
|
|
449
483
|
// state for runIds is wiped. No persistence to clean up.
|
|
450
484
|
this.tokens.clear();
|
|
451
485
|
// Task 10: drop the taskId → token index with it.
|
|
452
486
|
this.taskTokens.clear();
|
|
453
|
-
|
|
454
|
-
// 4. Unlink the recorded socket file IF we created it. We never
|
|
455
|
-
// touch any other path. We also never `process.kill` anything.
|
|
456
|
-
const sockPath = this.resolvedSocketPath ?? this.options.socketPath;
|
|
457
|
-
if (sockPath && process.platform !== "win32") {
|
|
458
|
-
try {
|
|
459
|
-
await fsp.unlink(sockPath);
|
|
460
|
-
} catch (err) {
|
|
461
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
462
|
-
if (code !== "ENOENT") {
|
|
463
|
-
logInternalError(
|
|
464
|
-
"crew-broker.stop.unlink-failed",
|
|
465
|
-
err instanceof Error ? err : new Error(String(err)),
|
|
466
|
-
`path=${redactSecretString(sockPath)}`,
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
this.resolvedSocketPath = null;
|
|
472
487
|
}
|
|
473
488
|
|
|
474
489
|
// Connection lifecycle
|
|
@@ -759,15 +774,7 @@ export class CrewBroker {
|
|
|
759
774
|
}
|
|
760
775
|
}
|
|
761
776
|
|
|
762
|
-
//
|
|
763
|
-
if (typeof taskId !== "string" || taskId.length === 0 || taskId.length > 256) {
|
|
764
|
-
this.sendErrorAndClose(conn, id, "auth", "hello rejected");
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
if (typeof runId !== "string" || runId.length === 0 || runId.length > 256) {
|
|
768
|
-
this.sendErrorAndClose(conn, id, "auth", "hello rejected");
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
777
|
+
// taskId/runId/token bounds (non-empty, ≤256) are enforced upstream by isHelloParams — protocol/request-parsers.ts.
|
|
771
778
|
|
|
772
779
|
// Bind connection identity and ack. Ack never includes the token.
|
|
773
780
|
conn.authed = true;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* socket-stop.ts — Ownership-gated teardown of the broker's listening socket.
|
|
3
|
+
*
|
|
4
|
+
* An AF_UNIX listener has TWO unlink vectors, and both must be owned-gated:
|
|
5
|
+
* 1. The explicit `fs.unlink` of the recorded path (CrewBroker.stop step 4).
|
|
6
|
+
* 2. libuv itself: `uv__pipe_close` unlinks the server pipe's bind path BY
|
|
7
|
+
* NAME (no inode check) when the listening handle closes — Node's
|
|
8
|
+
* `server.close()` deletes whatever currently sits at the path, even a
|
|
9
|
+
* socket another listener re-bound there after ours was unlinked
|
|
10
|
+
* (empirically verified on Node 22, 2026-10-26).
|
|
11
|
+
*
|
|
12
|
+
* The broker socket path is deterministic per sessionId, so a rebound leaf
|
|
13
|
+
* must survive our stop(). stopServerAndRemoveSocket therefore:
|
|
14
|
+
* - Unlinks the leaf itself FIRST when its dev+ino match the identity
|
|
15
|
+
* captured at listen() time — libuv's close-unlink then finds nothing.
|
|
16
|
+
* - Otherwise, if ANY entry lives at the path (rebound foreign socket, or
|
|
17
|
+
* ownership unprovable because the capture lstat failed), renames it
|
|
18
|
+
* aside (shield) across closeServer() and renames it back afterwards:
|
|
19
|
+
* a brief ENOENT window for new connects instead of permanent deletion
|
|
20
|
+
* of a live foreign socket. B-LOW2: the rename-back happens ONLY while
|
|
21
|
+
* sockPath is still absent — if a new listener re-bound the leaf during
|
|
22
|
+
* the close, its leaf wins and the shield is kept (renaming back would
|
|
23
|
+
* clobber the fresh bind).
|
|
24
|
+
* A stranded shield (crash between renames, or one kept after a rebind)
|
|
25
|
+
* keeps its `.stopping.` suffix and is swept by the corpse sweep inside
|
|
26
|
+
* removeStaleBrokerSocket at the next start — it unlinks a shield ONLY
|
|
27
|
+
* when the pid embedded in its name is provably dead (B-LOW1).
|
|
28
|
+
* - Windows named pipes have no filesystem leaf: plain close only.
|
|
29
|
+
*
|
|
30
|
+
* Never throws; diagnostics flow through logInternalError with redaction.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
|
+
import * as fsp from "node:fs/promises";
|
|
35
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
36
|
+
import { redactSecretString } from "../../utils/redaction.ts";
|
|
37
|
+
|
|
38
|
+
/** dev+ino of the socket leaf created by listen() — captured immediately
|
|
39
|
+
* after a successful bind so stop() can prove continued ownership of the
|
|
40
|
+
* path before removing it. Null when the lstat failed (stop() then treats
|
|
41
|
+
* the leaf as NOT provably ours — the safe direction). */
|
|
42
|
+
export interface SocketIdentity {
|
|
43
|
+
dev: number;
|
|
44
|
+
ino: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Capture the identity of the freshly-bound socket leaf (POSIX only). */
|
|
48
|
+
export async function captureSocketIdentity(sockPath: string): Promise<SocketIdentity | null> {
|
|
49
|
+
try {
|
|
50
|
+
const st = await fsp.lstat(sockPath);
|
|
51
|
+
return { dev: st.dev, ino: st.ino };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SocketStopOutcome =
|
|
58
|
+
/** Our leaf (identity matched) was unlinked by us before the close. */
|
|
59
|
+
| "unlinked"
|
|
60
|
+
/** A foreign/unpinned entry existed and was shielded across the close. */
|
|
61
|
+
| "shielded-foreign"
|
|
62
|
+
/** A foreign entry existed but the shield rename failed (close then
|
|
63
|
+
* unlinks it by name — the pre-fix behavior; logged loudly). */
|
|
64
|
+
| "unshielded-foreign"
|
|
65
|
+
/** Nothing lived at the path (or win32: no fs leaf at all). */
|
|
66
|
+
| "absent";
|
|
67
|
+
|
|
68
|
+
/** Close the listening server and remove the socket path ONLY while we
|
|
69
|
+
* provably own it. `closeServer` must perform the net.Server close (the
|
|
70
|
+
* libuv by-name unlink vector) — it is called exactly once, sandwiched
|
|
71
|
+
* inside the ownership decision. Never throws. */
|
|
72
|
+
export async function stopServerAndRemoveSocket(
|
|
73
|
+
sockPath: string,
|
|
74
|
+
identity: SocketIdentity | null,
|
|
75
|
+
closeServer: () => Promise<void>,
|
|
76
|
+
): Promise<SocketStopOutcome> {
|
|
77
|
+
if (process.platform === "win32") {
|
|
78
|
+
await closeServer();
|
|
79
|
+
return "absent";
|
|
80
|
+
}
|
|
81
|
+
const current = await fsp.lstat(sockPath).catch(() => null);
|
|
82
|
+
if (current === null) {
|
|
83
|
+
await closeServer();
|
|
84
|
+
return "absent";
|
|
85
|
+
}
|
|
86
|
+
const owned = identity !== null && current.dev === identity.dev && current.ino === identity.ino;
|
|
87
|
+
if (owned) {
|
|
88
|
+
// Remove our own leaf first so libuv's close-unlink finds nothing.
|
|
89
|
+
// (Microsecond TOCTOU between this unlink and the close-unlink is
|
|
90
|
+
// inherent to path-based APIs — accepted, same class as before.)
|
|
91
|
+
await fsp.unlink(sockPath).catch(() => {
|
|
92
|
+
/* best-effort: the leaf may already be gone */
|
|
93
|
+
});
|
|
94
|
+
await closeServer();
|
|
95
|
+
return "unlinked";
|
|
96
|
+
}
|
|
97
|
+
// Foreign or unprovably-owned entry: shield it across the close.
|
|
98
|
+
const shield = `${sockPath}.stopping.${process.pid}.${randomBytes(3).toString("hex")}`;
|
|
99
|
+
let shielded = false;
|
|
100
|
+
try {
|
|
101
|
+
await fsp.rename(sockPath, shield);
|
|
102
|
+
shielded = true;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logInternalError(
|
|
105
|
+
"crew-broker.stop.shield-rename-failed",
|
|
106
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
107
|
+
`path=${redactSecretString(sockPath)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await closeServer();
|
|
112
|
+
} finally {
|
|
113
|
+
if (shielded) {
|
|
114
|
+
// B-LOW2: restore ONLY while the leaf is still absent. While our
|
|
115
|
+
// handle was closing, a new listener may have bound sockPath — a
|
|
116
|
+
// rename back would REPLACE its fresh leaf (rename clobbers the
|
|
117
|
+
// target), recreating the exact deletion bug the shield exists to
|
|
118
|
+
// prevent. When rebound: keep the shield (the corpse sweep at the
|
|
119
|
+
// next start removes it once this pid is dead) and log loudly.
|
|
120
|
+
let rebound = false;
|
|
121
|
+
try {
|
|
122
|
+
await fsp.lstat(sockPath);
|
|
123
|
+
rebound = true;
|
|
124
|
+
} catch {
|
|
125
|
+
/* still absent — safe to restore */
|
|
126
|
+
}
|
|
127
|
+
if (rebound) {
|
|
128
|
+
logInternalError(
|
|
129
|
+
"crew-broker.stop.shield-kept-rebound",
|
|
130
|
+
new Error(`socket path re-bound during close — keeping shield at ${shield} instead of clobbering the new listener`),
|
|
131
|
+
`path=${redactSecretString(sockPath)}`,
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
try {
|
|
135
|
+
await fsp.rename(shield, sockPath);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
logInternalError(
|
|
138
|
+
"crew-broker.stop.shield-restore-failed",
|
|
139
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
140
|
+
`path=${redactSecretString(shield)}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return shielded ? "shielded-foreign" : "unshielded-foreign";
|
|
147
|
+
}
|
|
@@ -57,6 +57,11 @@ const identityByPid = new Map<number, ProcIdentity>();
|
|
|
57
57
|
// Wave 11a injection seam (same shape as zombie-scanner.ts `__test`): tests seed
|
|
58
58
|
// identity snapshots and swap the group reaper without a real child process.
|
|
59
59
|
let reapGroupImpl: typeof reapGroup = reapGroup;
|
|
60
|
+
// Wave 16 (E1): injectable /proc identity reader for killProcessPid's PID-recycle
|
|
61
|
+
// re-validation — tests simulate a recycled starttime without racing the OS for
|
|
62
|
+
// a real pid reuse. registerActiveChild deliberately keeps the REAL reader (its
|
|
63
|
+
// identityByPid snapshot is production /proc state, not a test fixture).
|
|
64
|
+
let readProcIdentityImpl: typeof readProcIdentity = readProcIdentity;
|
|
60
65
|
export const __test = {
|
|
61
66
|
/** Seed the exit-time identity snapshot for a pid (as registerActiveChild would). */
|
|
62
67
|
seedIdentity(pid: number, identity: ProcIdentity | undefined): void {
|
|
@@ -71,6 +76,10 @@ export const __test = {
|
|
|
71
76
|
setReapGroup(fn: typeof reapGroup | undefined): void {
|
|
72
77
|
reapGroupImpl = fn ?? reapGroup;
|
|
73
78
|
},
|
|
79
|
+
/** Replace the /proc identity reader used by killProcessPid's E1 re-validation (undefined restores the real one). */
|
|
80
|
+
setReadProcIdentity(fn: typeof readProcIdentity | undefined): void {
|
|
81
|
+
readProcIdentityImpl = fn ?? readProcIdentity;
|
|
82
|
+
},
|
|
74
83
|
};
|
|
75
84
|
|
|
76
85
|
// Periodic cleanup of dead child process entries to prevent memory leaks.
|
|
@@ -173,9 +182,24 @@ export function killProcessPid(pid: number): void {
|
|
|
173
182
|
void owned.dispose();
|
|
174
183
|
return;
|
|
175
184
|
}
|
|
185
|
+
// E1 (fix-wave 16): baseline the pid's /proc starttime (stat field 22) while
|
|
186
|
+
// it is alive at kill entry; the hard-kill timer below re-reads it and
|
|
187
|
+
// refuses to signal a CHANGED starttime — the original process exited during
|
|
188
|
+
// the grace window and an unrelated process recycled the pid. Effectively
|
|
189
|
+
// Linux-only: Windows has no /proc (readProcIdentity → undefined ⇒ no
|
|
190
|
+
// baseline ⇒ the timer keeps the pre-wave unconditional behavior — see the
|
|
191
|
+
// win32 branch note below), and /proc-less POSIX (macOS) degrades the same
|
|
192
|
+
// way. Deliberately NOT the registration-snapshot pattern (identityByPid):
|
|
193
|
+
// external pid-only callers — live-session agents, cancel paths on stale
|
|
194
|
+
// pids — have no registration to consult.
|
|
195
|
+
const baselineStarttime = process.platform === "win32" ? undefined : readProcIdentityImpl(pid)?.starttime;
|
|
176
196
|
try {
|
|
177
197
|
if (process.platform === "win32") {
|
|
178
198
|
// 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
|
|
199
|
+
// E1 note: Windows has no /proc, so no starttime baseline/re-validation
|
|
200
|
+
// is possible here — behavior stays exactly as before. taskkill /T /F on
|
|
201
|
+
// a recycled pid fails harmlessly ("process not found"), so the residual
|
|
202
|
+
// recycle window is not actionable on this platform.
|
|
179
203
|
// taskkill itself can silently fail (PID gone, permission denied, etc.)
|
|
180
204
|
// so verify after 2s and log a warning if the process is still alive.
|
|
181
205
|
spawnTaskkillSafe(pid);
|
|
@@ -213,6 +237,26 @@ export function killProcessPid(pid: number): void {
|
|
|
213
237
|
}
|
|
214
238
|
clearHardKillTimer(pid);
|
|
215
239
|
const hardKillTimer = setTimeout(() => {
|
|
240
|
+
// E1: PID-recycle re-validation immediately before the hard kill —
|
|
241
|
+
// guards BOTH the group kill (-pid) below and the absolute-pid fallback
|
|
242
|
+
// inside its catch. Skip ONLY on a proven mismatch (baseline known AND
|
|
243
|
+
// current known AND different): an unreadable current value means the pid
|
|
244
|
+
// is already gone (the kill below reports ESRCH exactly as before) or
|
|
245
|
+
// /proc is unavailable on this platform (there was never a baseline to
|
|
246
|
+
// compare against).
|
|
247
|
+
const currentStarttime = readProcIdentityImpl(pid)?.starttime;
|
|
248
|
+
if (baselineStarttime !== undefined && currentStarttime !== undefined && currentStarttime !== baselineStarttime) {
|
|
249
|
+
logInternalError(
|
|
250
|
+
"child-pi.sigkill-pid-recycled",
|
|
251
|
+
new Error(
|
|
252
|
+
`starttime changed between SIGTERM and the hard-kill timer (${baselineStarttime} → ${currentStarttime}) — pid was recycled, skipping hard kill of the unrelated new occupant`,
|
|
253
|
+
),
|
|
254
|
+
`pid=${pid}`,
|
|
255
|
+
"warn",
|
|
256
|
+
);
|
|
257
|
+
childHardKillTimers.delete(pid);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
216
260
|
try {
|
|
217
261
|
process.kill(-pid, "SIGKILL");
|
|
218
262
|
} catch (error) {
|
|
@@ -36,7 +36,11 @@
|
|
|
36
36
|
* Anthropic usage events; per-worker in-flight cap; revoke(workerKey) /
|
|
37
37
|
* revokeAll() kill-switch (revoked token → 401 on the NEXT request; in-flight
|
|
38
38
|
* requests are allowed to drain — killing sockets mid-stream corrupts
|
|
39
|
-
* transcripts without adding security, the token is already dead).
|
|
39
|
+
* transcripts without adding security, the token is already dead). The token
|
|
40
|
+
* map is keyed by the SHA-256 digest of each token, never the raw secret
|
|
41
|
+
* (constant-time discipline, same pattern as crew-broker-tokens.ts); after
|
|
42
|
+
* registerInferenceWorker hands the raw token to the caller, the broker
|
|
43
|
+
* retains only its digest.
|
|
40
44
|
*
|
|
41
45
|
* Honest boundary: the broker protects the REUSABLE CREDENTIAL channel only.
|
|
42
46
|
* A worker can still read other host files (~/.ssh, ~/.aws, ~/.pi/agent/
|
|
@@ -88,7 +92,10 @@ const STRIP_RESPONSE_HEADERS = new Set(["content-encoding", "content-length", "t
|
|
|
88
92
|
|
|
89
93
|
interface WorkerEntry {
|
|
90
94
|
workerKey: string;
|
|
91
|
-
token
|
|
95
|
+
/** SHA-256 hex digest of the worker token — the RAW secret is handed to
|
|
96
|
+
* the caller by registerInferenceWorker and never retained here
|
|
97
|
+
* (constant-time discipline, fix-wave TASK [5]). */
|
|
98
|
+
tokenDigest: string;
|
|
92
99
|
revoked: boolean;
|
|
93
100
|
requests: number;
|
|
94
101
|
inputTokens: number;
|
|
@@ -97,6 +104,15 @@ interface WorkerEntry {
|
|
|
97
104
|
maxTokens: number;
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
/** Map key for the per-worker token registry: the SHA-256 hex digest of the
|
|
108
|
+
* token, NEVER the raw secret — mirrors BrokerTokenRegistry.hashToken
|
|
109
|
+
* (runtime/broker/crew-broker-tokens.ts). Hashed locally with the identical
|
|
110
|
+
* implementation instead of importing the registry class, so this module's
|
|
111
|
+
* static import graph is untouched. */
|
|
112
|
+
function tokenKey(token: string): string {
|
|
113
|
+
return crypto.createHash("sha256").update(token, "utf8").digest("hex");
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
export interface InferenceBrokerSnapshot {
|
|
101
117
|
active: boolean;
|
|
102
118
|
baseUrl: string;
|
|
@@ -121,7 +137,7 @@ interface BrokerState {
|
|
|
121
137
|
server: http.Server;
|
|
122
138
|
port: number;
|
|
123
139
|
routes: Map<string, ProviderRoute>; // by path prefix ("/anthropic", "/openai")
|
|
124
|
-
workers: Map<string, WorkerEntry>; // by token
|
|
140
|
+
workers: Map<string, WorkerEntry>; // by SHA-256 token digest (never the raw secret)
|
|
125
141
|
byKey: Map<string, WorkerEntry>; // by workerKey
|
|
126
142
|
scopedAgentDir: string;
|
|
127
143
|
}
|
|
@@ -381,7 +397,7 @@ async function handleRequest(
|
|
|
381
397
|
}
|
|
382
398
|
// (1) Auth — fail-closed, generic errors (never echo tokens).
|
|
383
399
|
const token = extractToken(req);
|
|
384
|
-
const worker = token ? ctx.workers.get(token) : undefined;
|
|
400
|
+
const worker = token ? ctx.workers.get(tokenKey(token)) : undefined;
|
|
385
401
|
if (!worker || worker.revoked) {
|
|
386
402
|
deny(res, 401, "invalid or revoked inference token");
|
|
387
403
|
return;
|
|
@@ -535,13 +551,22 @@ export function registerInferenceWorker(workerKey: string): string {
|
|
|
535
551
|
const existing = broker.byKey.get(workerKey);
|
|
536
552
|
if (existing) {
|
|
537
553
|
existing.revoked = true;
|
|
538
|
-
broker.workers.delete(existing.
|
|
554
|
+
broker.workers.delete(existing.tokenDigest);
|
|
539
555
|
broker.byKey.delete(workerKey);
|
|
540
556
|
}
|
|
541
557
|
const token = crypto.randomBytes(32).toString("hex");
|
|
542
558
|
const maxTokens = getCrewEnvInt(INFERENCE_BUDGET_ENV) ?? 0;
|
|
543
|
-
const entry: WorkerEntry = {
|
|
544
|
-
|
|
559
|
+
const entry: WorkerEntry = {
|
|
560
|
+
workerKey,
|
|
561
|
+
tokenDigest: tokenKey(token),
|
|
562
|
+
revoked: false,
|
|
563
|
+
requests: 0,
|
|
564
|
+
inputTokens: 0,
|
|
565
|
+
outputTokens: 0,
|
|
566
|
+
inFlight: 0,
|
|
567
|
+
maxTokens,
|
|
568
|
+
};
|
|
569
|
+
broker.workers.set(entry.tokenDigest, entry);
|
|
545
570
|
broker.byKey.set(workerKey, entry);
|
|
546
571
|
return token;
|
|
547
572
|
}
|
|
@@ -574,7 +599,7 @@ export function revokeInferenceWorker(workerKey: string): boolean {
|
|
|
574
599
|
const entry = broker.byKey.get(workerKey);
|
|
575
600
|
if (!entry) return false;
|
|
576
601
|
entry.revoked = true;
|
|
577
|
-
broker.workers.delete(entry.
|
|
602
|
+
broker.workers.delete(entry.tokenDigest);
|
|
578
603
|
broker.byKey.delete(workerKey);
|
|
579
604
|
return true;
|
|
580
605
|
}
|
|
@@ -616,3 +641,10 @@ export function stopInferenceBroker(): void {
|
|
|
616
641
|
export function __resetInferenceBrokerForTests(): void {
|
|
617
642
|
stopInferenceBroker();
|
|
618
643
|
}
|
|
644
|
+
|
|
645
|
+
/** @internal TESTS ONLY — the live token-map keys. By construction these are
|
|
646
|
+
* SHA-256 digests (see tokenKey); the suite pins that no RAW token ever
|
|
647
|
+
* appears as a map key (constant-time discipline). */
|
|
648
|
+
export function __inferenceBrokerTokenMapKeysForTests(): string[] {
|
|
649
|
+
return broker ? [...broker.workers.keys()] : [];
|
|
650
|
+
}
|
|
@@ -24,6 +24,7 @@ import { spawnSync } from "node:child_process";
|
|
|
24
24
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
25
25
|
import { findRepoRoot } from "../../utils/paths.ts";
|
|
26
26
|
import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
27
|
+
import { buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Roles whose job is to EDIT project source/test files (changes that appear in
|
|
@@ -54,7 +55,15 @@ export function isGitWorkingTreeClean(cwd: string): { clean: boolean; repoRoot:
|
|
|
54
55
|
const repoRoot = findRepoRoot(cwd);
|
|
55
56
|
if (!repoRoot) return { clean: "unknown", repoRoot: undefined };
|
|
56
57
|
// `git status --porcelain` → empty stdout means a clean working tree.
|
|
57
|
-
|
|
58
|
+
// R-A5 (MED-1): previously spawnSync inherited the FULL process.env —
|
|
59
|
+
// API keys could leak to config-reachable helpers git spawns. Now the
|
|
60
|
+
// sanitized env + hardenGitArgs (cwd is the trusted manifest repo root,
|
|
61
|
+
// so no gitdir-pointer guard is needed here).
|
|
62
|
+
const res = spawnSync("git", hardenGitArgs(["-C", repoRoot, "status", "--porcelain"]), {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
timeout: 5_000,
|
|
65
|
+
env: buildGitSafeEnv(),
|
|
66
|
+
});
|
|
58
67
|
if (res.error || res.status !== 0) return { clean: "unknown", repoRoot: undefined };
|
|
59
68
|
return { clean: res.stdout.trim().length === 0, repoRoot };
|
|
60
69
|
} catch {
|
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -260,7 +260,16 @@ function resolvePiCliScript(): string | undefined {
|
|
|
260
260
|
process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@earendil-works", "pi-coding-agent") : undefined,
|
|
261
261
|
process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@mariozechner", "pi-coding-agent") : undefined,
|
|
262
262
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
263
|
-
process.cwd()
|
|
263
|
+
// E2 (fix-wave 16): the process.cwd() root is deliberately REMOVED.
|
|
264
|
+
// findPiPackageJsonFrom matches package.json by NAME only (no version or
|
|
265
|
+
// signature check), so a booby-trapped working directory carrying a fake
|
|
266
|
+
// package.json (name=@earendil-works/pi-coding-agent + bin) would get its
|
|
267
|
+
// attacker binary spawned with the worker env — which carries the
|
|
268
|
+
// inference-broker token. Legitimate installs stay covered by the roots
|
|
269
|
+
// above (argv1 package root, `npm root -g`, %APPDATA%\npm, and the
|
|
270
|
+
// import.meta install-dir walk, which still scans every ancestor
|
|
271
|
+
// node_modules chain via findPiPackageJsonFrom) plus the bare-"pi" PATH
|
|
272
|
+
// fallback in getPiSpawnCommand.
|
|
264
273
|
].filter((entry): entry is string => Boolean(entry));
|
|
265
274
|
|
|
266
275
|
for (const root of roots) {
|
|
@@ -119,6 +119,22 @@ export function hasFreshTaskHeartbeatEvidence(
|
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Wave 16 (residual render wiring, GAP-4): tight heartbeat-liveness guard for
|
|
124
|
+
* ACTIVE display — mirrors the orphan predicate's own preconditions (active
|
|
125
|
+
* status, non-async) so a terminal or async run is never resurrected by a
|
|
126
|
+
* heartbeat. A healthy direct run mid-long-tool freezes manifest `updatedAt`
|
|
127
|
+
* and agent progress (event-driven writes) but keeps stamping
|
|
128
|
+
* `task.heartbeat.lastSeenAt`, so fresh evidence means the run is alive.
|
|
129
|
+
* Single home for the guard that run-dashboard previously kept private and
|
|
130
|
+
* agents-jobs-browser inlined; call sites OR this in ONLY — every non-stale
|
|
131
|
+
* branch and layout stays untouched. PURE: takes the tasks the caller already
|
|
132
|
+
* holds in scope (no disk read here).
|
|
133
|
+
*/
|
|
134
|
+
export function heartbeatLiveForDisplay(run: TeamRunManifest, tasks: TeamTaskState[] | undefined, now = Date.now()): boolean {
|
|
135
|
+
return isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(tasks, now);
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
export function isLikelyOrphanedActiveRun(
|
|
123
139
|
run: TeamRunManifest,
|
|
124
140
|
agents: CrewAgentRecord[] = [],
|