@staix/agent-hub 0.12.21 → 0.12.22
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 +10 -0
- package/README.md +13 -6
- package/docs/agent-notes/adapters.md +6 -3
- package/docs/agent-notes/benchmarks.md +1 -1
- package/docs/agent-notes/bus.md +2 -1
- package/docs/agent-notes/daemon.md +4 -0
- package/docs/agent-notes/local-worker.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +13 -4
- package/docs/operations.md +253 -22
- package/docs/quickstart.md +6 -3
- package/docs/security.md +9 -6
- package/docs/smoke.md +61 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +38 -5
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +84 -8
- package/docs/verified.json +21 -15
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/acp.ts +10 -4
- package/src/adapters/codex-appserver.ts +27 -22
- package/src/adapters/local-worker.ts +44 -10
- package/src/adapters/pi.ts +158 -19
- package/src/cli/help.ts +6 -5
- package/src/cli/identity.ts +2 -2
- package/src/cli/launch.ts +182 -7
- package/src/cli/main.ts +137 -18
- package/src/cli/recovery-package.ts +17 -0
- package/src/cli/upgrade-interactive.ts +297 -0
- package/src/cli/upgrade-runtime.ts +56 -2
- package/src/cli/upgrade.ts +27 -10
- package/src/hub/bus.ts +5 -3
- package/src/hub/daemon.ts +145 -60
- package/src/hub/events.ts +2 -1
- package/src/hub/peers.ts +2 -2
- package/src/hub/recovery-store.ts +38 -0
- package/src/hub/tasks.ts +32 -15
- package/src/local/deny.ts +48 -1
- package/src/local/sandbox.ts +6 -3
- package/src/local/tools.ts +25 -6
- package/src/pi/extension.ts +43 -20
- package/src/pi/tool-receipts.ts +17 -1
- package/src/ui/index.html +15 -11
package/src/adapters/pi.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { BasePeer } from "../hub/peers.ts";
|
|
|
8
8
|
import { stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
9
9
|
import { processSignature } from "../pi/process-signature.ts";
|
|
10
10
|
import { realPath } from "../hub/project.ts";
|
|
11
|
-
import { toolResultFailed } from "../local/tools.ts";
|
|
11
|
+
import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
|
|
12
12
|
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
13
13
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
14
14
|
|
|
@@ -25,10 +25,13 @@ export interface PiExit {
|
|
|
25
25
|
toolActive: boolean;
|
|
26
26
|
lastToolName?: string;
|
|
27
27
|
}
|
|
28
|
+
export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
|
|
29
|
+
export interface PiToolResult { text: string; exitCode: number | null; }
|
|
30
|
+
class PiToolSettlementFailure extends Error {}
|
|
28
31
|
export interface PiOptions {
|
|
29
32
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
30
33
|
model?: string;
|
|
31
|
-
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal) => Promise<string>; tools: PiToolSchema[];
|
|
34
|
+
relay: PiRelay; executeTool: (name: string, args: unknown, toolCallId: string, sessionId?: string, signal?: AbortSignal, context?: PiToolContext) => Promise<string | PiToolResult>; tools: PiToolSchema[];
|
|
32
35
|
preamble?: string;
|
|
33
36
|
selectModel?: (envs: Envelope[]) => Promise<string | undefined>; maxSteps?: number;
|
|
34
37
|
/** Atomic task/run admission immediately before provider requests or tool execution. */
|
|
@@ -40,6 +43,7 @@ export interface PiOptions {
|
|
|
40
43
|
watchdogMs?: number; log?: (line: string) => void;
|
|
41
44
|
/** Once per owner, after it is offline; a TUI owner may have unknown OS exit status. */
|
|
42
45
|
onExit?: (exit: PiExit) => void;
|
|
46
|
+
onApprovalStop?: (reason: string) => void;
|
|
43
47
|
/** How long stop() waits for a graceful TUI owner exit before verified teardown. Tests shrink this. */
|
|
44
48
|
stopGraceMs?: number;
|
|
45
49
|
}
|
|
@@ -77,8 +81,17 @@ export class PiPeer extends BasePeer {
|
|
|
77
81
|
private persistedOnce = false;
|
|
78
82
|
private activeEnvs: Envelope[] = [];
|
|
79
83
|
private activeTools = 0;
|
|
84
|
+
private readonly pendingToolGenerations = new Map<string, { generation: number; abort: AbortController; settled: Promise<void> }>();
|
|
85
|
+
private readonly abortedToolIds = new Set<string>();
|
|
86
|
+
private abortIdsOverflow = false;
|
|
80
87
|
private readonly activeDeliveryIds = new Set<string>();
|
|
81
88
|
private agentRunning = false;
|
|
89
|
+
private approvalExpiries = 0;
|
|
90
|
+
private approvalAnswerEpoch: number | undefined;
|
|
91
|
+
private approvalTurnAbort = false;
|
|
92
|
+
private approvalAbortSent = false;
|
|
93
|
+
private approvalStopReason = "";
|
|
94
|
+
private requestedStopReason: string | undefined;
|
|
82
95
|
private owner = randomUUID();
|
|
83
96
|
private _tuiLaunch?: PiTuiLaunch;
|
|
84
97
|
private requestedModel = "";
|
|
@@ -180,11 +193,18 @@ export class PiPeer extends BasePeer {
|
|
|
180
193
|
}
|
|
181
194
|
private async handleToolRequest(body: any): Promise<Response> {
|
|
182
195
|
if (this.stopping || this.state === "offline") return Response.json({ text: "error: Pi owner is stopped" }, { status: 409 });
|
|
196
|
+
if (!this.verifiedOwner() || typeof body.sessionId !== "string" || !body.sessionId || body.sessionId !== this.sessionId || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) {
|
|
197
|
+
const text = "error: Pi tool lineage is missing, stale or unverified; restart Pi with the current hub extension before retrying";
|
|
198
|
+
this.opts.log?.(`[${this.id}] ${text}`);
|
|
199
|
+
return Response.json({ text, failed: true }, { status: 409 });
|
|
200
|
+
}
|
|
201
|
+
if (this.abortIdsOverflow || this.abortedToolIds.has(String(body.toolCallId))) return Response.json({ text: "error: tool call cancelled before execution", failed: true });
|
|
202
|
+
if (this.pendingToolGenerations.has(String(body.toolCallId ?? ""))) return Response.json({ text: "error: this Pi tool call is already in progress", failed: true }, { status: 409 });
|
|
183
203
|
let executionSignal = this.executionAbort?.signal;
|
|
184
204
|
let idleBashTimer: ReturnType<typeof setTimeout> | undefined;
|
|
185
205
|
if (body.purpose === "idle_user_bash") {
|
|
186
206
|
const reservation = typeof body.reservation === "string" ? this.idleBashReservations.get(body.reservation) : undefined;
|
|
187
|
-
if (!reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
|
|
207
|
+
if (this.opts.mode !== "tui" || !this.verifiedOwner() || !reservation || reservation.expiresAt < Date.now() || reservation.generation !== body.generation || !this.ownerClaimed || this.ownerPid === undefined || this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 || !Number.isSafeInteger(body.generation) || body.generation !== this.budgetGeneration) return Response.json({ text: "error: stale or non-idle Pi user shell request" }, { status: 409 });
|
|
188
208
|
if (reservation.deadlineAt !== undefined && reservation.deadlineAt <= Date.now()) {
|
|
189
209
|
this.idleBashReservations.delete(String(body.reservation));
|
|
190
210
|
return Response.json({ text: "error: Pi user shell execution budget expired" }, { status: 409 });
|
|
@@ -195,6 +215,18 @@ export class PiPeer extends BasePeer {
|
|
|
195
215
|
if (reservation.deadlineAt !== undefined) idleBashTimer = setTimeout(() => idleBashAbort.abort(), Math.max(0, reservation.deadlineAt - Date.now()));
|
|
196
216
|
}
|
|
197
217
|
this.noteActivity();
|
|
218
|
+
const generation = this.budgetGeneration;
|
|
219
|
+
const turnSignal = this.executionAbort?.signal;
|
|
220
|
+
const parentSignal = executionSignal;
|
|
221
|
+
const toolAbort = new AbortController();
|
|
222
|
+
const abortTool = () => toolAbort.abort();
|
|
223
|
+
parentSignal?.addEventListener("abort", abortTool, { once: true });
|
|
224
|
+
if (parentSignal?.aborted) abortTool();
|
|
225
|
+
executionSignal = toolAbort.signal;
|
|
226
|
+
const callId = String(body.toolCallId ?? "");
|
|
227
|
+
let settled!: () => void;
|
|
228
|
+
const settlement = new Promise<void>((resolve) => { settled = resolve; });
|
|
229
|
+
this.pendingToolGenerations.set(callId, { generation, abort: toolAbort, settled: settlement });
|
|
198
230
|
const name = String(body.name);
|
|
199
231
|
this.lastToolName = this.opts.tools.some((tool) => tool.name === name) && /^[a-zA-Z0-9_.-]{1,128}$/.test(name) ? name : "unknown";
|
|
200
232
|
this.activeTools++;
|
|
@@ -203,11 +235,40 @@ export class PiPeer extends BasePeer {
|
|
|
203
235
|
// `failed` is the managed-tool failure verdict (toolResultFailed, the one contract): Pi 1.0.1
|
|
204
236
|
// classifies a native tool result by isError === true alone, so the extension needs the flag,
|
|
205
237
|
// not just the text (#181). An older extension simply ignores the extra field.
|
|
206
|
-
try {
|
|
238
|
+
try {
|
|
239
|
+
const result = await this.opts.executeTool(name, body.args, callId, this.sessionId, executionSignal, {
|
|
240
|
+
userBash: body.purpose === "idle_user_bash",
|
|
241
|
+
isCurrent: () => generation === this.budgetGeneration && !this.stopping && this.state !== "offline" && !executionSignal?.aborted && !this.approvalStopReason && this.verifiedOwner() && (body.purpose === "idle_user_bash" ? this.opts.mode === "tui" : turnSignal === this.executionAbort?.signal),
|
|
242
|
+
onApproval: (provenance) => {
|
|
243
|
+
if (!this.agentRunning || generation !== this.budgetGeneration || turnSignal !== this.executionAbort?.signal || this.approvalStopReason) return;
|
|
244
|
+
if (this.approvalAnswerEpoch !== provenance.answerEpoch) {
|
|
245
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = provenance.answerEpoch;
|
|
246
|
+
}
|
|
247
|
+
if (provenance.source === "person") this.approvalExpiries = 0;
|
|
248
|
+
if (provenance.source === "expired" && provenance.eligibleExpiry !== false && ++this.approvalExpiries >= 2) {
|
|
249
|
+
this.approvalStopReason = "Pi turn stopped after two unanswered approvals; no person answered. Do not retry the calls; inspect prior work before handoff or stopping.";
|
|
250
|
+
try { this.opts.onApprovalStop?.(this.approvalStopReason); } catch { /* stop remains authoritative */ }
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
const text = typeof result === "string" ? result : result.text;
|
|
255
|
+
return Response.json({ text, failed: typeof result !== "string" && result.exitCode !== null ? result.exitCode !== 0 : toolResultFailed(name, text), ...(typeof result === "string" ? {} : { exitCode: result.exitCode }) });
|
|
256
|
+
}
|
|
207
257
|
catch (error) { return Response.json({ text: `error: ${(error as Error).message}`, failed: true }, { status: 200 }); }
|
|
208
258
|
finally {
|
|
209
259
|
clearTimeout(idleBashTimer);
|
|
210
260
|
this.activeTools--;
|
|
261
|
+
parentSignal?.removeEventListener("abort", abortTool);
|
|
262
|
+
if (this.pendingToolGenerations.get(callId)?.abort === toolAbort) this.pendingToolGenerations.delete(callId);
|
|
263
|
+
settled();
|
|
264
|
+
if (this.approvalStopReason && !this.approvalAbortSent) {
|
|
265
|
+
this.approvalAbortSent = true;
|
|
266
|
+
const reason = this.approvalStopReason;
|
|
267
|
+
// Finish this request's expiry reply before withdrawing siblings and aborting the native turn.
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
if (this.agentRunning && generation === this.budgetGeneration && this.approvalStopReason === reason && !this.stopping) void this.endApprovalTurn(generation, reason);
|
|
270
|
+
}, 10);
|
|
271
|
+
}
|
|
211
272
|
if (this.state === "busy") {
|
|
212
273
|
if (!this.activeTools && !this.agentRunning && !this.activeEnvs.length) this.setState("idle");
|
|
213
274
|
else this.touch();
|
|
@@ -218,12 +279,14 @@ export class PiPeer extends BasePeer {
|
|
|
218
279
|
private async handleBudgetRequest(body: any): Promise<Response> {
|
|
219
280
|
if (!["model_calls", "tool_calls"].includes(String(body.unit))) return Response.json({ error: "invalid Pi budget unit" }, { status: 400 });
|
|
220
281
|
const idleUserBash = body.idleUserBash === true;
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
|
|
282
|
+
const generation = body.generation, controller = this.executionAbort, session = this.sessionId, ownerPid = this.ownerPid, ownerSignature = this.ownerSignature;
|
|
283
|
+
const current = () => Number.isSafeInteger(generation) && generation === this.budgetGeneration && controller === this.executionAbort && session === this.sessionId && ownerPid === this.ownerPid && ownerSignature === this.ownerSignature && !this.stopping && this.verifiedOwner();
|
|
284
|
+
const idleOwner = this.opts.mode === "tui" && !this.stopping && this.verifiedOwner() && this.state === "idle" && !this.agentRunning && this.activeTools === 0 && this.activeEnvs.length === 0;
|
|
285
|
+
if (!current() || (idleUserBash ? !idleOwner : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "stale Pi budget request" }, { status: 409 });
|
|
224
286
|
// An interactive user shell has no task delivery. It may spend an eligible run budget only.
|
|
225
287
|
const envs = idleUserBash ? [] : this.budgetEnvelopes;
|
|
226
288
|
const decisions = this.opts.admitBudget ? await this.opts.admitBudget(envs, body.unit as ExecutionUnit) : [];
|
|
289
|
+
if (!current() || (idleUserBash ? this.state !== "idle" || this.agentRunning || this.activeTools > 0 || this.activeEnvs.length > 0 : !this.agentRunning || this.state !== "busy")) return Response.json({ error: "Pi owner or turn changed during admission" }, { status: 409 });
|
|
227
290
|
const denied = decisions.find((decision) => !decision.allowed);
|
|
228
291
|
const remaining = decisions.filter((decision) => decision.unit === "elapsed_ms" && decision.remaining !== null).reduce<number | undefined>((min, decision) => min === undefined ? decision.remaining! : Math.min(min, decision.remaining!), undefined);
|
|
229
292
|
let reservation: string | undefined;
|
|
@@ -248,7 +311,10 @@ export class PiPeer extends BasePeer {
|
|
|
248
311
|
|
|
249
312
|
private async startImpl(): Promise<void> {
|
|
250
313
|
this.stopping = false;
|
|
314
|
+
this.requestedStopReason = undefined;
|
|
315
|
+
this.approvalTurnAbort = false;
|
|
251
316
|
this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
|
|
317
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
252
318
|
mkdirSync(this.opts.stateDir, { recursive: true });
|
|
253
319
|
const sessions = join(this.opts.stateDir, "pi-sessions");
|
|
254
320
|
mkdirSync(sessions, { recursive: true, mode: 0o700 });
|
|
@@ -311,17 +377,48 @@ export class PiPeer extends BasePeer {
|
|
|
311
377
|
throw new Error("Pi get_state session identity does not match the requested recovery session");
|
|
312
378
|
}
|
|
313
379
|
this.sessionId = state.data.sessionId; this.sessionFile = state.data.sessionFile;
|
|
380
|
+
// The hub spawned this headless owner and get_state proved its session. Pin the same
|
|
381
|
+
// identity a native extension claim uses, including fakes that do not load the extension.
|
|
382
|
+
if (!this.ownerClaimed) {
|
|
383
|
+
const pid = this.proc?.pid, signature = pid ? processSignature(pid) : undefined;
|
|
384
|
+
if (!pid || !signature) throw new Error("Pi headless owner process identity is not verified");
|
|
385
|
+
this.ownerPid = pid; this.ownerSignature = signature;
|
|
386
|
+
this.ownerToken = String(this._tuiLaunch?.env.AGENTHUB_PI_OWNER_TOKEN ?? ""); this.ownerClaimed = true;
|
|
387
|
+
}
|
|
314
388
|
this.persistedOnce = existsSync(this.sessionFile);
|
|
315
389
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) throw new Error("Pi startup session identity mismatch");
|
|
316
390
|
this.started = true;
|
|
317
391
|
this.setState("idle");
|
|
318
392
|
}
|
|
319
393
|
|
|
320
|
-
|
|
394
|
+
private verifiedOwner(): boolean {
|
|
395
|
+
return this.ownerClaimed && !!this.ownerPid && !!this.ownerSignature && processSignature(this.ownerPid) === this.ownerSignature;
|
|
396
|
+
}
|
|
397
|
+
private abortTools(): Promise<void>[] {
|
|
398
|
+
this.executionAbort?.abort();
|
|
399
|
+
this.idleBashReservations.clear();
|
|
400
|
+
const pending = [...this.pendingToolGenerations.values()];
|
|
401
|
+
for (const call of pending) call.abort.abort();
|
|
402
|
+
return pending.map((call) => call.settled);
|
|
403
|
+
}
|
|
404
|
+
get mode(): "headless" | "tui" { return this.opts.mode; }
|
|
405
|
+
|
|
406
|
+
async stop(reason?: string): Promise<void> {
|
|
407
|
+
if (reason !== undefined) this.requestedStopReason = reason;
|
|
408
|
+
this.stopping = true; // fence new calls before withdrawing both turn and idle-shell execution
|
|
409
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: reason ?? "Pi session stopped before settlement" });
|
|
410
|
+
this.activeDeliveryIds.clear();
|
|
411
|
+
const settling = this.abortTools();
|
|
412
|
+
let unsettled: unknown;
|
|
413
|
+
if (settling.length) {
|
|
414
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
415
|
+
try {
|
|
416
|
+
await Promise.race([Promise.all(settling), new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new PiToolSettlementFailure("Pi managed tools did not settle after cancellation")), this.opts.stopGraceMs ?? 5_000); })]);
|
|
417
|
+
} catch (error) { unsettled = error; } // a tool past its grace must not block the teardown (#253): stop reports it at the end
|
|
418
|
+
finally { clearTimeout(timer); }
|
|
419
|
+
}
|
|
321
420
|
const tuiExit = this.opts.mode === "tui" && this.ownerClaimed && this.ownerPid && this.ownerSignature
|
|
322
421
|
? { ...this.exitMetadata("owner_stopped"), expected: true } : undefined;
|
|
323
|
-
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi session stopped before settlement" });
|
|
324
|
-
this.activeDeliveryIds.clear();
|
|
325
422
|
this.stopping = true;
|
|
326
423
|
this.clearOwnerMonitor();
|
|
327
424
|
if (this.opts.mode === "tui") {
|
|
@@ -361,6 +458,7 @@ export class PiPeer extends BasePeer {
|
|
|
361
458
|
this.server?.stop(true); this.server = undefined; this.setState("offline");
|
|
362
459
|
if (failed) throw failed;
|
|
363
460
|
if (tuiExit) this.reportExit(tuiExit); // the verified owner is gone; never invent an exit for an unclaimed launch
|
|
461
|
+
if (unsettled) throw unsettled;
|
|
364
462
|
}
|
|
365
463
|
|
|
366
464
|
/**
|
|
@@ -443,8 +541,10 @@ export class PiPeer extends BasePeer {
|
|
|
443
541
|
}
|
|
444
542
|
|
|
445
543
|
private handleBridgeEvent(event: any): void {
|
|
544
|
+
if (this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) return;
|
|
446
545
|
if (event.type === "session_start") {
|
|
447
546
|
if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
|
|
547
|
+
this.approvalTurnAbort = event.approvalTurnAbort === true;
|
|
448
548
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
449
549
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
450
550
|
if ((this.opts.sessionId && this.sessionId !== this.opts.sessionId) || (this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)) { this.opts.log?.(`[${this.id}] Pi session identity mismatch`); return; }
|
|
@@ -457,11 +557,24 @@ export class PiPeer extends BasePeer {
|
|
|
457
557
|
this.fail(new Error("Pi session shut down before settlement"));
|
|
458
558
|
if (this.opts.mode === "tui") this.reportExit(exit);
|
|
459
559
|
}
|
|
560
|
+
if (event.type === "tool_abort" && this.agentRunning && this.ownerClaimed && this.ownerPid && this.ownerSignature
|
|
561
|
+
&& processSignature(this.ownerPid) === this.ownerSignature && event.sessionId === this.sessionId && event.generation === this.budgetGeneration
|
|
562
|
+
&& typeof event.toolCallId === "string" && event.toolCallId.length > 0 && event.toolCallId.length <= 512) {
|
|
563
|
+
const pending = this.pendingToolGenerations.get(event.toolCallId);
|
|
564
|
+
if (pending && pending.generation === event.generation) pending.abort.abort();
|
|
565
|
+
else { // a native abort may arrive before its HTTP tool request; fence that call in this generation
|
|
566
|
+
// ponytail: 64 early aborts per turn; fence further calls rather than forget a cancellation.
|
|
567
|
+
// A native per-call acknowledgement would replace this bounded early-arrival cache.
|
|
568
|
+
if (this.abortedToolIds.size >= 64 && !this.abortedToolIds.has(event.toolCallId)) this.abortIdsOverflow = true;
|
|
569
|
+
else this.abortedToolIds.add(event.toolCallId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
460
572
|
if (event.type === "agent_start") {
|
|
461
573
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration + 1;
|
|
462
574
|
if (generation <= this.budgetGeneration) return;
|
|
463
575
|
this.modelStep = 0;
|
|
464
|
-
this.
|
|
576
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false; this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
577
|
+
this.usageSeen.clear(); this.ceilingStops.clear(); this.abortTools(); this.executionAbort = new AbortController(); this.budgetGeneration = generation; this.noteActivity(); this.agentRunning = true; this.setState("busy");
|
|
465
578
|
}
|
|
466
579
|
// #179: the extension's tool-step ceiling signal. Only a schema-valid event bound to THIS session,
|
|
467
580
|
// THIS turn generation and a running turn is stored; the first signal of a turn stands. Anything
|
|
@@ -495,7 +608,8 @@ export class PiPeer extends BasePeer {
|
|
|
495
608
|
this.agentRunning = false;
|
|
496
609
|
if (typeof event.text === "string" && event.text.trim()) this.settledText = event.text;
|
|
497
610
|
const generation = Number.isSafeInteger(event.generation) ? event.generation : this.budgetGeneration;
|
|
498
|
-
const
|
|
611
|
+
const approvalStopReason = this.approvalStopReason;
|
|
612
|
+
const text = this.settledText.trim(); const error = approvalStopReason || this.budgetStops.get(generation) || this.settledError; const cancelled = !error && this.settledCancelled;
|
|
499
613
|
const ceiling = this.ceilingStops.get(generation);
|
|
500
614
|
this.budgetStops.delete(generation);
|
|
501
615
|
this.ceilingStops.delete(generation);
|
|
@@ -505,12 +619,16 @@ export class PiPeer extends BasePeer {
|
|
|
505
619
|
const reply = { inReplyTo: this.currentReply, to: replyAudience(this.activeEnvs) };
|
|
506
620
|
if (cancelled) this.onMessage?.("Pi turn cancelled; inspect any partial effects before continuing.", reply);
|
|
507
621
|
else if (error?.startsWith("execution budget")) this.onMessage?.(`Pi stopped at the execution budget: ${error}. Inspect partial work before continuing.`, reply);
|
|
622
|
+
else if (approvalStopReason) this.onMessage?.(approvalStopReason, reply);
|
|
508
623
|
else if (error) void this.opts.onTurnFailure?.(this.activeEnvs, error, ceiling);
|
|
509
624
|
else if (text) this.onMessage?.(text, reply);
|
|
510
|
-
for (const id of this.activeDeliveryIds) this.delivery({ id, state: error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
625
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: approvalStopReason ? "completed" : error || cancelled ? "needs_review" : "completed", ...(error || cancelled ? { reason: error || "Pi turn cancelled; partial effects are possible" } : {}) });
|
|
511
626
|
this.activeDeliveryIds.clear();
|
|
512
|
-
this.currentReply = undefined; this.activeEnvs = [];
|
|
513
|
-
this.executionAbort = undefined;
|
|
627
|
+
this.currentReply = undefined; this.activeEnvs = [];
|
|
628
|
+
this.executionAbort?.abort(); this.executionAbort = undefined;
|
|
629
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
630
|
+
this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
631
|
+
if (this.state === "busy" && !this.activeTools) this.setState("idle");
|
|
514
632
|
}
|
|
515
633
|
}
|
|
516
634
|
|
|
@@ -560,14 +678,19 @@ export class PiPeer extends BasePeer {
|
|
|
560
678
|
}
|
|
561
679
|
|
|
562
680
|
private fail(error: Error): void {
|
|
681
|
+
const approvalStopReason = this.approvalStopReason;
|
|
682
|
+
this.abortTools();
|
|
683
|
+
this.agentRunning = false;
|
|
684
|
+
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
685
|
+
this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
563
686
|
for (const pending of this.pending.values()) pending.reject(error);
|
|
564
687
|
this.pending.clear();
|
|
565
688
|
const envs = this.activeEnvs; this.activeEnvs = [];
|
|
566
689
|
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: error.message });
|
|
567
690
|
this.activeDeliveryIds.clear();
|
|
568
|
-
if (envs.length) {
|
|
569
|
-
this.onMessage?.("Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
|
|
570
|
-
void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
|
|
691
|
+
if (envs.length && !this.requestedStopReason) {
|
|
692
|
+
this.onMessage?.(approvalStopReason ? `${approvalStopReason} Pi is offline because its extension could not abort the turn; restart with ahub pi and settle the held delivery after inspecting prior work.` : "Pi turn failed; inspect its session and any partial effects before continuing.", { inReplyTo: this.currentReply });
|
|
693
|
+
if (!approvalStopReason && !(error instanceof ApprovalWaitStop)) void this.opts.onTurnFailure?.(envs, error.message).catch(() => this.opts.log?.("Pi failure handoff could not be completed"));
|
|
571
694
|
}
|
|
572
695
|
this.currentReply = undefined;
|
|
573
696
|
this.setState("offline");
|
|
@@ -588,6 +711,17 @@ export class PiPeer extends BasePeer {
|
|
|
588
711
|
this.ownerMonitor.unref?.();
|
|
589
712
|
}
|
|
590
713
|
private clearOwnerMonitor(): void { if (this.ownerMonitor) clearInterval(this.ownerMonitor); this.ownerMonitor = undefined; }
|
|
714
|
+
private async endApprovalTurn(generation: number, reason: string): Promise<void> {
|
|
715
|
+
if (!this.agentRunning || generation !== this.budgetGeneration || this.approvalStopReason !== reason || this.stopping || this.state === "offline") return;
|
|
716
|
+
this.executionAbort?.abort();
|
|
717
|
+
if (this.approvalTurnAbort) {
|
|
718
|
+
try { await this.sendTui({ type: "abort_budget", generation, cause: "approval", reason }); return; }
|
|
719
|
+
catch { if (generation !== this.budgetGeneration || !this.agentRunning) return; }
|
|
720
|
+
}
|
|
721
|
+
this.opts.log?.(`[${this.id}] approval turn abort unsupported; Pi goes offline. Restart with ahub pi and settle the held delivery after inspecting prior work.`);
|
|
722
|
+
await this.terminateFailedTurn(new ApprovalWaitStop(reason));
|
|
723
|
+
}
|
|
724
|
+
|
|
591
725
|
private async terminateFailedTurn(error: Error): Promise<void> {
|
|
592
726
|
const envs = this.activeEnvs.slice(), reply = this.currentReply;
|
|
593
727
|
this.stopping = true;
|
|
@@ -595,7 +729,12 @@ export class PiPeer extends BasePeer {
|
|
|
595
729
|
await this.stop();
|
|
596
730
|
// The process exit handler may already have reported this failure.
|
|
597
731
|
if (this.activeEnvs.length) this.fail(error);
|
|
598
|
-
} catch {
|
|
732
|
+
} catch (stopError) {
|
|
733
|
+
if (stopError instanceof PiToolSettlementFailure && this.state === "offline") {
|
|
734
|
+
this.opts.log?.(`Pi teardown completed with a managed-tool settlement failure: ${(stopError as Error).message}`);
|
|
735
|
+
if (this.activeEnvs.length) this.fail(error);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
599
738
|
this.activeEnvs = envs; this.currentReply = reply;
|
|
600
739
|
this.stopping = true;
|
|
601
740
|
this.setState("busy");
|
package/src/cli/help.ts
CHANGED
|
@@ -24,11 +24,11 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
24
24
|
["ahub doctor --orphans [--kill]", "list registrations whose project root is gone; --kill stops their daemons (SIGTERM, then SIGKILL) only after the process identity checks out"],
|
|
25
25
|
["ahub kill", "stop this project's hub"],
|
|
26
26
|
["ahub reset [--all] [--yes]", "list, then with --yes stop the hub and discard every queued and needs_review delivery, clear holds and pauses and drop session pointers; --all moves the state directory to .agenthub/archive instead"],
|
|
27
|
-
["ahub restart [--dry-run] [--yes]", "recover this project's runtime"],
|
|
28
|
-
["ahub upgrade --to <version> [--dry-run] [--yes]", "review and upgrade running projects"],
|
|
29
|
-
["ahub recovery status|resume|abort <operation-id>", "inspect, resume or cancel a preflight; status names the next actions"],
|
|
30
|
-
["ahub recovery dispose <operation-id> --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
31
|
-
["ahub recovery dispose <operation-id> --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
27
|
+
["ahub restart [--dry-run] [--yes]", "recover this project's runtime; on a terminal without a flag it reviews the plan with you, follows the progress and offers the next steps"],
|
|
28
|
+
["ahub upgrade [--to <version>] [--dry-run] [--yes]", "review and upgrade running projects; --to defaults to the latest release (--yes needs it), and a newer release's own coordinator takes over; on a terminal without --dry-run or --yes: review, end agents first if you choose, apply, follow, and resume, cancel, end or reset on the spot"],
|
|
29
|
+
["ahub recovery [status|resume|abort] [<operation-id>]", "inspect, resume or cancel a preflight; status names the next actions; without an id, the operation that holds the lock, and with nothing else on a terminal its screen"],
|
|
30
|
+
["ahub recovery dispose [<operation-id>] --fresh-session codex|claude --reason <text>", "end a partial operation by starting that peer as a new session, recording the lost conversation"],
|
|
31
|
+
["ahub recovery dispose [<operation-id>] --stop-and-archive --reason <text>", "end a partial operation: stop its own targets, archive its snapshot and release the lock; the upgrade is abandoned, not completed"],
|
|
32
32
|
["ahub daemon [--unattended]", "internal: the detached hub process that ahub up or the dashboard manager starts"],
|
|
33
33
|
["ahub recovery-run <operation-id>", "internal: the detached runner of an upgrade, restart or recovery resume"],
|
|
34
34
|
]],
|
|
@@ -38,6 +38,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
38
38
|
["ahub kimi [--print-command] [--model <alias>]", "start Kimi headless under ACP"],
|
|
39
39
|
["ahub pi [--print-command] [--mode headless|tui] [--backend auto|dgx|mlx] [--session-id <id>] [--session-file <path>]", "start Pi"],
|
|
40
40
|
["ahub local [--route <id> | --model <id>]", "start the hub-native worker on the self-hosted models (routing.toml)"],
|
|
41
|
+
["ahub stop <peer>", "human only: stop a hub-owned headless peer; end TUI agents in their terminals"],
|
|
41
42
|
["ahub models setup|status|start|stop", "prepare or inspect local Ollama MLX (legacy stop is explicit)"],
|
|
42
43
|
]],
|
|
43
44
|
["Messages and approvals", [
|
package/src/cli/identity.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type PeerCommandAccess = "allowed" | "conductor" | "console";
|
|
|
26
26
|
|
|
27
27
|
/** A closed list: unknown commands cannot acquire console authority through an agent shell. */
|
|
28
28
|
export function classifyPeerCommand(command: string, args: readonly string[]): PeerCommandAccess {
|
|
29
|
-
if (command === "permission") return "console";
|
|
29
|
+
if (command === "permission" || command === "stop") return "console";
|
|
30
30
|
if (args.some((arg) => arg === "--as-user" || arg.startsWith("--as-user="))) return "console";
|
|
31
31
|
if (command === "task") {
|
|
32
32
|
if (["propose", "show"].includes(args[0] ?? "")) return "allowed";
|
|
@@ -47,7 +47,7 @@ export function classifyPeerCommand(command: string, args: readonly string[]): P
|
|
|
47
47
|
|
|
48
48
|
/** No argument text is included: notices and refusal logs must not copy task text or credentials. */
|
|
49
49
|
export function cliCommandLabel(command: string, args: readonly string[]): string {
|
|
50
|
-
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
50
|
+
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "pi", "local", "stop", "models", "say", "queue", "tail", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "status", "logs", "export", "report", "research", "bench", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
51
51
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "bench"].includes(command)) {
|
|
52
52
|
const sub = command === "bench" ? args.find((arg) => arg !== "--json") : args[0];
|
|
53
53
|
if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "run", "report", "compare"].includes(sub)) return `${command} ${sub}`;
|
package/src/cli/launch.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Launchers inject only the flags the hub owns and refuse user-supplied duplicates.
|
|
2
|
-
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, readFileSync, openSync, closeSync, readSync, accessSync, constants, linkSync } from "node:fs";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
4
5
|
import { resolve, join, basename, dirname, isAbsolute } from "node:path";
|
|
5
6
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
6
7
|
import { realPath } from "../hub/project.ts";
|
|
7
|
-
import { peerChildEnv } from "../hub/child-process.ts";
|
|
8
|
+
import { peerChildEnv, processTable } from "../hub/child-process.ts";
|
|
8
9
|
export const CLAUDE_CHANNEL = "plugin:agent-hub@agent-hub";
|
|
9
10
|
|
|
10
11
|
/** Launch identity belongs to the native child, never to the agent that invoked the wrapper. */
|
|
@@ -65,14 +66,188 @@ export function cleanupClaudeSettings(stateDir: string, previous: unknown): void
|
|
|
65
66
|
/** Crash fallback only: an absent/unreadable owner identity never certifies that its native launch ended. */
|
|
66
67
|
export function cleanupStaleClaudeSettings(stateDir: string, previous: unknown, identity = processSignature): void {
|
|
67
68
|
if (!previous || typeof previous !== "object" || Array.isArray(previous)) return;
|
|
68
|
-
const
|
|
69
|
+
const original = previous as Record<string, unknown>;
|
|
70
|
+
const record = readClaudeNative(stateDir, original as ClaudeLaunchRecord) ?? original;
|
|
69
71
|
if (typeof record.launcherSignature !== "string" || !record.launcherSignature) return;
|
|
70
72
|
if (processLiveness(record.launcherPid, record.launcherSignature, identity) !== "gone") return;
|
|
71
|
-
// ponytail: spawnSync launch records have no independent native identity, so crash-left settings are retained.
|
|
72
|
-
// Upgrade path: record the native child's PID and processSignature before admitting crash fallback cleanup.
|
|
73
73
|
if (typeof record.nativePid !== "number" || !Number.isSafeInteger(record.nativePid) || record.nativePid <= 0 || record.nativePid === record.launcherPid || typeof record.nativeSignature !== "string" || !record.nativeSignature) return;
|
|
74
|
-
if (
|
|
74
|
+
if (record.nativeIdentity === "direct-child") {
|
|
75
|
+
// A direct child may exec in place. A changed signature while its PID still runs cannot certify native exit.
|
|
76
|
+
// The shared table excludes exited zombies; an unavailable table certifies nothing.
|
|
77
|
+
const table = processTable();
|
|
78
|
+
if (!table || table.some(row => row.pid === record.nativePid)) return;
|
|
79
|
+
} else if (processLiveness(record.nativePid, record.nativeSignature, identity) !== "gone") return;
|
|
75
80
|
cleanupClaudeSettings(stateDir, record.settingsFile);
|
|
81
|
+
removeClaudeNative(stateDir, original as ClaudeLaunchRecord);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ClaudeLaunchRecord extends Record<string, unknown> {
|
|
85
|
+
instanceId: string; launchId: string; settingsFile?: string; launcherPid: number; launcherSignature?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ClaudeLaunchPublication { recorded: boolean; warning?: string; }
|
|
89
|
+
|
|
90
|
+
/** Native attestation alone is serialized; managed hook metadata never waits on this lock. */
|
|
91
|
+
async function withClaudeLaunchRecord(stateDir: string, action: () => void): Promise<ClaudeLaunchPublication> {
|
|
92
|
+
const lock = join(stateDir, "claude-launch.lock"), temp = `${lock}.${randomUUID()}.tmp`;
|
|
93
|
+
const skipped = (reason = `${lock} is busy or unverified`): ClaudeLaunchPublication => ({ recorded: false, warning: `Native identity and crash cleanup disabled for this launch; managed hook record stays current. ${reason}; inspect ${lock} and verify its owner has ended before removing it.` });
|
|
94
|
+
const signature = processSignature(process.pid);
|
|
95
|
+
if (!signature) return skipped("launcher identity could not be verified");
|
|
96
|
+
let locked = false, tempCreated = false, own: { dev: number; ino: number } | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const fd = openSync(temp, "wx", 0o600); tempCreated = true;
|
|
99
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, signature })); } finally { closeSync(fd); }
|
|
100
|
+
const stat = lstatSync(temp); own = { dev: stat.dev, ino: stat.ino };
|
|
101
|
+
const deadline = performance.now() + 250;
|
|
102
|
+
for (;;) {
|
|
103
|
+
try { linkSync(temp, lock); break; }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") return skipped();
|
|
106
|
+
let before;
|
|
107
|
+
try { before = lstatSync(lock); } catch { if (performance.now() < deadline) continue; return skipped(); }
|
|
108
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || !process.getuid || before.uid !== process.getuid()) return skipped();
|
|
109
|
+
let owner: { pid?: unknown; signature?: unknown };
|
|
110
|
+
try { owner = JSON.parse(readFileSync(lock, "utf8")); } catch { return skipped(); }
|
|
111
|
+
if (!owner || typeof owner.signature !== "string" || !owner.signature) return skipped();
|
|
112
|
+
const alive = processLiveness(owner.pid, owner.signature);
|
|
113
|
+
if (alive === "unknown") return skipped();
|
|
114
|
+
if (alive === "live") {
|
|
115
|
+
if (performance.now() >= deadline) return skipped();
|
|
116
|
+
await new Promise(resolve => setTimeout(resolve, 25)); continue;
|
|
117
|
+
}
|
|
118
|
+
const current = lstatSync(lock);
|
|
119
|
+
if (current.dev !== before.dev || current.ino !== before.ino) return skipped();
|
|
120
|
+
unlinkSync(lock);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
locked = true;
|
|
124
|
+
unlinkSync(temp);
|
|
125
|
+
try { action(); } catch { return skipped("native attestation publication failed"); }
|
|
126
|
+
return { recorded: true };
|
|
127
|
+
} catch { return skipped("native attestation lock could not be read or published"); }
|
|
128
|
+
finally {
|
|
129
|
+
if (locked && own) {
|
|
130
|
+
try { const current = lstatSync(lock); if (current.dev === own.dev && current.ino === own.ino) unlinkSync(lock); }
|
|
131
|
+
catch { /* only our verified lock may be removed */ }
|
|
132
|
+
}
|
|
133
|
+
if (tempCreated) { try { unlinkSync(temp); } catch { /* our populated temp may already be linked and consumed */ } }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeClaudeLaunchRecord(stateDir: string, record: Record<string, unknown>): void {
|
|
138
|
+
const file = join(stateDir, "claude-launch.json"), temp = `${file}.${randomUUID()}.tmp`;
|
|
139
|
+
try { writeFileSync(temp, JSON.stringify(record), { mode: 0o600 }); renameSync(temp, file); }
|
|
140
|
+
finally { try { unlinkSync(temp); } catch { /* the rename normally consumed it */ } }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function recordClaudeLaunch(stateDir: string, record: ClaudeLaunchRecord): ClaudeLaunchPublication {
|
|
144
|
+
writeClaudeLaunchRecord(stateDir, record);
|
|
145
|
+
return { recorded: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sameClaudeLaunch(left: ClaudeLaunchRecord, right: ClaudeLaunchRecord): boolean {
|
|
149
|
+
return left.instanceId === right.instanceId && left.launchId === right.launchId && left.settingsFile === right.settingsFile
|
|
150
|
+
&& left.launcherPid === right.launcherPid && left.launcherSignature === right.launcherSignature;
|
|
151
|
+
}
|
|
152
|
+
function nativeAttestationPath(stateDir: string, launch: ClaudeLaunchRecord): string {
|
|
153
|
+
const hash = createHash("sha256").update(JSON.stringify([launch.instanceId, launch.launchId, launch.settingsFile, launch.launcherPid, launch.launcherSignature])).digest("hex");
|
|
154
|
+
return join(stateDir, `claude-native-${hash}.json`);
|
|
155
|
+
}
|
|
156
|
+
function privateClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): { file: string; stat: ReturnType<typeof lstatSync>; record: ClaudeLaunchRecord } | undefined {
|
|
157
|
+
const file = nativeAttestationPath(stateDir, launch);
|
|
158
|
+
try {
|
|
159
|
+
const stat = lstatSync(file);
|
|
160
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
161
|
+
const record = JSON.parse(readFileSync(file, "utf8")) as ClaudeLaunchRecord;
|
|
162
|
+
if (!record || !sameClaudeLaunch(record, launch) || typeof record.nativePid !== "number" || !Number.isSafeInteger(record.nativePid) || record.nativePid <= 0 || typeof record.nativeSignature !== "string" || !record.nativeSignature || record.nativeIdentity !== "direct-child") return;
|
|
163
|
+
return { file, stat, record };
|
|
164
|
+
} catch { return; }
|
|
165
|
+
}
|
|
166
|
+
export function readClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): ClaudeLaunchRecord | undefined {
|
|
167
|
+
return privateClaudeNative(stateDir, launch)?.record;
|
|
168
|
+
}
|
|
169
|
+
function removeClaudeNative(stateDir: string, launch: ClaudeLaunchRecord): void {
|
|
170
|
+
const saved = privateClaudeNative(stateDir, launch); if (!saved?.stat) return;
|
|
171
|
+
try { const now = lstatSync(saved.file); if (now.dev === saved.stat.dev && now.ino === saved.stat.ino) unlinkSync(saved.file); }
|
|
172
|
+
catch { /* absent or replaced native proof remains untouched */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Never attach an old child's identity to a replacement launch, even when its spawn callback arrives later. */
|
|
176
|
+
export async function recordClaudeNative(stateDir: string, launch: ClaudeLaunchRecord, pid: number, signature: string, report?: (warning: string) => void): Promise<boolean> {
|
|
177
|
+
let recorded = false;
|
|
178
|
+
const publication = await withClaudeLaunchRecord(stateDir, () => {
|
|
179
|
+
let current: ClaudeLaunchRecord;
|
|
180
|
+
try {
|
|
181
|
+
const file = join(stateDir, "claude-launch.json"), stat = lstatSync(file);
|
|
182
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o777) !== 0o600 || !process.getuid || stat.uid !== process.getuid()) return;
|
|
183
|
+
current = JSON.parse(readFileSync(file, "utf8"));
|
|
184
|
+
} catch { return; }
|
|
185
|
+
if (!sameClaudeLaunch(current, launch)) return;
|
|
186
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || pid === launch.launcherPid || !signature) return;
|
|
187
|
+
const file = nativeAttestationPath(stateDir, launch), temp = `${file}.${randomUUID()}.tmp`;
|
|
188
|
+
try {
|
|
189
|
+
lstatSync(file);
|
|
190
|
+
const saved = privateClaudeNative(stateDir, launch);
|
|
191
|
+
recorded = !!saved && saved.record.nativePid === pid && saved.record.nativeSignature === signature;
|
|
192
|
+
return; // a launch's first native proof is immutable; an unknown file is never overwritten
|
|
193
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; }
|
|
194
|
+
try {
|
|
195
|
+
writeFileSync(temp, JSON.stringify({ ...launch, nativePid: pid, nativeSignature: signature, nativeIdentity: "direct-child" }), { mode: 0o600, flag: "wx" });
|
|
196
|
+
renameSync(temp, file);
|
|
197
|
+
} finally { try { unlinkSync(temp); } catch { /* atomic rename consumed our private temporary file */ } }
|
|
198
|
+
try { recorded = sameClaudeLaunch(JSON.parse(readFileSync(join(stateDir, "claude-launch.json"), "utf8")), launch); } catch { /* replaced or unreadable managed record */ }
|
|
199
|
+
});
|
|
200
|
+
if (publication.warning) report?.(publication.warning);
|
|
201
|
+
return publication.recorded && recorded;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Scripts and interpreters can leave a different native owner behind: their crash cleanup remains disabled. */
|
|
205
|
+
function directNativeExecutable(command: string, cwd: string, env: NodeJS.ProcessEnv): boolean {
|
|
206
|
+
const interpreter = /^(?:node|bun|deno|env|busybox|python(?:[0-9.]+)?|ruby|perl|bash|sh|zsh|fish|dash|ksh|csh|tcsh|ash)(?:\.exe)?$/;
|
|
207
|
+
if (interpreter.test(basename(command))) return false;
|
|
208
|
+
const paths = command.includes("/") ? [resolve(cwd, command)] : (env.PATH ?? "").split(":").filter(Boolean).map(path => resolve(cwd, path, command));
|
|
209
|
+
for (const path of paths) {
|
|
210
|
+
let fd: number | undefined;
|
|
211
|
+
try {
|
|
212
|
+
const target = realPath(path);
|
|
213
|
+
accessSync(target, constants.X_OK);
|
|
214
|
+
if (interpreter.test(basename(target))) return false;
|
|
215
|
+
fd = openSync(target, "r");
|
|
216
|
+
const bytes = Buffer.alloc(4); if (readSync(fd, bytes, 0, 4, 0) !== 4) return false;
|
|
217
|
+
return ["7f454c46", "feedface", "cefaedfe", "feedfacf", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca"].includes(bytes.toString("hex"));
|
|
218
|
+
} catch { /* resolve the next PATH entry */ }
|
|
219
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Same native stdio/environment and exit result as spawnSync, with a live child identity before it exits. */
|
|
225
|
+
export async function runClaudeLaunch(launch: Launch, options: { cwd: string; env: NodeJS.ProcessEnv; stateDir: string; record?: ClaudeLaunchRecord }): Promise<{ status: number | null; signal: NodeJS.Signals | null; error?: Error }> {
|
|
226
|
+
const direct = directNativeExecutable(launch.cmd, options.cwd, options.env);
|
|
227
|
+
return new Promise(resolveResult => {
|
|
228
|
+
let attestation: Promise<unknown> | undefined;
|
|
229
|
+
let childSignature: string | undefined;
|
|
230
|
+
const child = spawn(launch.cmd, launch.args, { cwd: options.cwd, env: options.env, stdio: "inherit" });
|
|
231
|
+
child.once("spawn", () => {
|
|
232
|
+
if (!options.record || !child.pid) return;
|
|
233
|
+
const unavailable = (reason: string) => console.error(`Native identity and crash cleanup disabled for this launch; managed hook record stays current. ${reason}.`);
|
|
234
|
+
if (!direct) { unavailable("native executable is a script or an unverified interpreter"); return; }
|
|
235
|
+
if (!options.record.launcherSignature) { unavailable("launcher identity could not be verified"); return; }
|
|
236
|
+
const signature = processSignature(child.pid);
|
|
237
|
+
if (!signature) { unavailable("native process identity could not be verified"); return; }
|
|
238
|
+
childSignature = signature;
|
|
239
|
+
attestation = recordClaudeNative(options.stateDir, options.record, child.pid, signature, warning => console.error(warning)).catch(() => undefined);
|
|
240
|
+
});
|
|
241
|
+
child.once("error", error => resolveResult({ status: null, signal: null, error }));
|
|
242
|
+
child.once("exit", async (status, signal) => {
|
|
243
|
+
await attestation;
|
|
244
|
+
if (options.record) {
|
|
245
|
+
const saved = readClaudeNative(options.stateDir, options.record);
|
|
246
|
+
if (saved && typeof child.pid === "number" && saved.nativePid === child.pid && saved.nativeSignature === childSignature) removeClaudeNative(options.stateDir, options.record);
|
|
247
|
+
} // this child's observed exit, after its last bounded write
|
|
248
|
+
resolveResult({ status, signal });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
76
251
|
}
|
|
77
252
|
|
|
78
253
|
export interface StatusLineTee {
|