@staix/agent-hub 0.12.24 → 0.12.26
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 +17 -1
- package/README.md +2 -2
- package/docs/agent-notes/adapters.md +2 -0
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +3 -0
- package/docs/data-policy.md +3 -2
- package/docs/memory.md +45 -8
- package/docs/operations.md +24 -14
- package/docs/quickstart.md +2 -2
- package/docs/smoke.md +16 -10
- package/docs/specs/2026-09-19-agent-hub-design.md +33 -1
- package/docs/text-projection.md +2 -2
- package/docs/verification/2026-10-14-0.12.24-audit.md +123 -0
- package/docs/verification/2026-10-14-0.12.25.md +27 -0
- package/docs/verification/2026-10-14-0.12.26.md +31 -0
- package/docs/verified.json +11 -11
- 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/pi.ts +79 -5
- package/src/cli/help.ts +1 -1
- package/src/cli/identity.ts +1 -1
- package/src/cli/memory.ts +16 -6
- package/src/hub/daemon.ts +7 -3
- package/src/hub/path-policy.ts +89 -11
- package/src/hub/tasks.ts +17 -6
- package/src/hub/text-projection.ts +38 -11
- package/src/hub/tooling.ts +19 -8
- package/src/memory/import.ts +52 -17
- package/src/memory/store.ts +62 -7
- package/src/pi/extension.ts +40 -5
- package/src/ui/index.html +5 -4
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# 0.12.26 release verification
|
|
2
|
+
|
|
3
|
+
This record covers release preparation and source verification. It does not certify tagging, package publication, global installation or a live upgrade; those receipts are recorded separately.
|
|
4
|
+
|
|
5
|
+
## Candidate
|
|
6
|
+
|
|
7
|
+
0.12.26 carries the hub-collaboration round for issues #380, #385 and #386: PRs #389, #387 and #388. Control protocol remains 16.
|
|
8
|
+
|
|
9
|
+
- #389 (#380): claude-mem imports derive their graph nodes from the observation text without consulting the checkout, so one observation is one content-hashed entry on every machine and sync converges regardless of import order; the import scan filters the project in SQL and resumes from a persisted machine-local cursor (`--restart` re-scans), so unrelated projects cannot exhaust the window; `ahub memory dedupe [--apply]` reconciles pre-fix duplicate groups with append-only supersede operations onto the lexicographically smallest id.
|
|
10
|
+
- #387 (#385): an exact or trailing-`**` data-policy entry that matches a directory resolves that directory's direct-child symlinks and protects their targets, matching what `data/*` already did for a matched link; discovery of the on-disk root is case-compatible, the scan streams inside the shared 4096-entry budget without descending, a dangling link covers its lexical target, an unresolvable link loop aliases nothing, a root whose budget is exhausted contributes no aliases, and an unreadable protected root fails closed. The failure semantics for the new scan are the user's direct ruling of 2026-10-14. The spec paragraph in docs/specs/2026-09-19-agent-hub-design.md was amended in the same PR.
|
|
11
|
+
- #388 (#386): the text-projection converter comment and both docs state the project-scoped read guarantee and the 1024-entry, cleared-when-full in-memory failure record.
|
|
12
|
+
|
|
13
|
+
Every fix carries regression tests that failed before it. Review was adversarial and multi-round on the board (tasks #45-#52): the review of #387 found the case-sensitive exact-root miss (P1) and the unbounded readdir (P2), CI found the Linux EACCES-at-read edge (#51) and two seed guards that needed retargeting after the refactor (#52), each fixed and re-gated before merge.
|
|
14
|
+
|
|
15
|
+
## Document review
|
|
16
|
+
|
|
17
|
+
Every stale document was rechecked against the ten source commits of the three PRs. README, Quickstart and Operations carry the 0.12.26 version and install/upgrade strings; Operations' converter failure-record wording landed with #388. The data-policy contract document (docs/data-policy.md) and docs/memory.md were updated inside their fix PRs. The adapters and daemon area notes were confirmed accurate against the `cliCommandLabel` addition, the tests note against the seed-selector repairs, and Security against the policy-matching change (it makes no per-form matching claim; the contract lives in data-policy.md). Budget, Bus, Benchmarks, Models, Tasks and the local-worker notes were not stale.
|
|
18
|
+
|
|
19
|
+
## Recorded checks
|
|
20
|
+
|
|
21
|
+
- PR #388 head 483ca546: CI Linux, macOS and seeded guards passed; board review approved.
|
|
22
|
+
- PR #389 head d5738472: worktree `scripts/check.sh` 2702 pass, 0 fail, 189163 assertions, `check: OK`, no daemon leaks; CI green; board review approved.
|
|
23
|
+
- PR #387 head 5fbe5d3a: worktree full gate 2714 pass, `check: OK`; CI Linux, macOS and seeded guards passed; board review approved.
|
|
24
|
+
- Release candidate fbf96c08: local `scripts/check.sh` in an isolated worktree — 2728 pass, 0 fail, 189269 assertions, `check: OK`, no daemon leaks. Later commits on this branch change documentation only.
|
|
25
|
+
- Release head: CI, document freshness at the merge commit, and the release workflow are required before tagging is reported complete.
|
|
26
|
+
|
|
27
|
+
Note: `scripts/check.sh` in the main checkout fails 35/68 daemon tests on unmodified main because the live hub daemon serving the project interferes; the isolated worktree gate is the valid signal (recorded in shared memory #194264).
|
|
28
|
+
|
|
29
|
+
## Not verified
|
|
30
|
+
|
|
31
|
+
Native, account and research legs remain open in #273 and #1. The installed runtime and live hubs are not upgraded by this release.
|
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
5
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
6
6
|
"paths": [
|
|
7
7
|
"package.json",
|
|
8
8
|
"src/",
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
"docs/security.md": {
|
|
14
|
-
"verifiedAgainst": "
|
|
14
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
21
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
22
22
|
"paths": [
|
|
23
23
|
"package.json",
|
|
24
24
|
"src/",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
37
|
"docs/quickstart.md": {
|
|
38
|
-
"verifiedAgainst": "
|
|
38
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
39
39
|
"paths": [
|
|
40
40
|
"package.json",
|
|
41
41
|
"src/",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"docs/agent-notes/adapters.md": {
|
|
46
|
-
"verifiedAgainst": "
|
|
46
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
47
47
|
"paths": [
|
|
48
48
|
"src/adapters/",
|
|
49
49
|
"src/pi/",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
]
|
|
63
63
|
},
|
|
64
64
|
"docs/agent-notes/benchmarks.md": {
|
|
65
|
-
"verifiedAgainst": "
|
|
65
|
+
"verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
|
|
66
66
|
"paths": [
|
|
67
67
|
"scripts/benchmarks/",
|
|
68
68
|
"test/benchmarks/",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
]
|
|
75
75
|
},
|
|
76
76
|
"docs/agent-notes/budget.md": {
|
|
77
|
-
"verifiedAgainst": "
|
|
77
|
+
"verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
|
|
78
78
|
"paths": [
|
|
79
79
|
"src/hub/budget.ts",
|
|
80
80
|
"src/cli/statusline-tee.ts",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
]
|
|
87
87
|
},
|
|
88
88
|
"docs/agent-notes/bus.md": {
|
|
89
|
-
"verifiedAgainst": "
|
|
89
|
+
"verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
|
|
90
90
|
"paths": [
|
|
91
91
|
"src/hub/bus.ts",
|
|
92
92
|
"src/hub/envelope.ts",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
]
|
|
99
99
|
},
|
|
100
100
|
"docs/agent-notes/daemon.md": {
|
|
101
|
-
"verifiedAgainst": "
|
|
101
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
102
102
|
"paths": [
|
|
103
103
|
"src/hub/daemon.ts",
|
|
104
104
|
"src/hub/control-client.ts",
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
]
|
|
156
156
|
},
|
|
157
157
|
"docs/agent-notes/tasks.md": {
|
|
158
|
-
"verifiedAgainst": "
|
|
158
|
+
"verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
|
|
159
159
|
"paths": [
|
|
160
160
|
"src/hub/tasks.ts",
|
|
161
161
|
"src/hub/board.ts",
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
]
|
|
173
173
|
},
|
|
174
174
|
"docs/agent-notes/tests.md": {
|
|
175
|
-
"verifiedAgainst": "
|
|
175
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
176
176
|
"paths": [
|
|
177
177
|
"test/",
|
|
178
178
|
"scripts/check.sh",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.26",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -16157,7 +16157,7 @@ import { join as join4 } from "path";
|
|
|
16157
16157
|
// package.json
|
|
16158
16158
|
var package_default = {
|
|
16159
16159
|
name: "@staix/agent-hub",
|
|
16160
|
-
version: "0.12.
|
|
16160
|
+
version: "0.12.26",
|
|
16161
16161
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
16162
16162
|
license: "MIT",
|
|
16163
16163
|
type: "module",
|
package/src/adapters/pi.ts
CHANGED
|
@@ -28,6 +28,10 @@ export interface PiExit {
|
|
|
28
28
|
export interface PiToolContext { userBash: boolean; isCurrent: () => boolean; onApproval: (provenance: ApprovalProvenance) => void; }
|
|
29
29
|
export interface PiToolResult { text: string; exitCode: number | null; }
|
|
30
30
|
class PiToolSettlementFailure extends Error {}
|
|
31
|
+
class PiPromptNotStarted extends Error {
|
|
32
|
+
constructor(readonly promptId: string) { super("Pi native session changed before prompt insertion; reverify its retained terminal before retrying"); }
|
|
33
|
+
}
|
|
34
|
+
class PiPromptUncertain extends Error {}
|
|
31
35
|
export interface PiOptions {
|
|
32
36
|
cwd: string; stateDir: string; cmd?: string[]; mode: "headless" | "tui"; backend: "auto" | "dgx" | "mlx"; sessionFile?: string; sessionId?: string;
|
|
33
37
|
model?: string;
|
|
@@ -101,6 +105,11 @@ export class PiPeer extends BasePeer {
|
|
|
101
105
|
private stopping = true;
|
|
102
106
|
private rosterDetached = false;
|
|
103
107
|
private rosterEpoch = 0;
|
|
108
|
+
private rosterLifecycleRevision = 0;
|
|
109
|
+
private fencedPrompt = false;
|
|
110
|
+
private hubPromptId: string | undefined;
|
|
111
|
+
private hubPromptGeneration: number | undefined;
|
|
112
|
+
private pendingPromptId: string | undefined;
|
|
104
113
|
/** A personal native turn observed on reattach has no hub reply audience. */
|
|
105
114
|
private rosterUnownedRun = false;
|
|
106
115
|
private exitReported = false;
|
|
@@ -319,6 +328,7 @@ export class PiPeer extends BasePeer {
|
|
|
319
328
|
this.rosterDetached = false; this.rosterUnownedRun = false;
|
|
320
329
|
this.requestedStopReason = undefined;
|
|
321
330
|
this.approvalTurnAbort = false;
|
|
331
|
+
this.fencedPrompt = false; this.hubPromptId = undefined; this.hubPromptGeneration = undefined; this.pendingPromptId = undefined;
|
|
322
332
|
this.exitReported = false; this.started = false; this.lastToolName = undefined; this.shutdownExit = undefined;
|
|
323
333
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
324
334
|
mkdirSync(this.opts.stateDir, { recursive: true });
|
|
@@ -347,7 +357,13 @@ export class PiPeer extends BasePeer {
|
|
|
347
357
|
if (url.pathname === "/ack") {
|
|
348
358
|
const pending = this.tuiCommands.get(String(body.id));
|
|
349
359
|
if (!pending) return Response.json({ ok: false }, { status: 404 });
|
|
350
|
-
this.tuiCommands.delete(String(body.id));
|
|
360
|
+
this.tuiCommands.delete(String(body.id));
|
|
361
|
+
const refusal = body.refusal;
|
|
362
|
+
const boundRefusal = this.fencedPrompt && this.verifiedOwner() && pending.command.type === "prompt" && pending.command.sessionId === this.sessionId
|
|
363
|
+
&& refusal?.promptId === pending.command.id && refusal.sessionId === this.sessionId
|
|
364
|
+
&& Number.isSafeInteger(refusal.generation) && refusal.generation >= Number(pending.command.generation);
|
|
365
|
+
body.ok ? pending.resolve(body.result) : pending.reject(boundRefusal && refusal.kind === "prompt_not_started" ? new PiPromptNotStarted(String(body.id))
|
|
366
|
+
: boundRefusal && refusal.kind === "prompt_uncertain" ? new PiPromptUncertain("Pi native prompt insertion is uncertain; inspect its retained terminal before retrying") : new Error(String(body.error ?? "Pi TUI command failed")));
|
|
351
367
|
return Response.json({ ok: true });
|
|
352
368
|
}
|
|
353
369
|
if (url.pathname === "/tool") return this.handleToolRequest(body);
|
|
@@ -437,10 +453,14 @@ export class PiPeer extends BasePeer {
|
|
|
437
453
|
async resumeFromRoster(): Promise<void> {
|
|
438
454
|
if (!this.rosterDetached) return;
|
|
439
455
|
if (this.opts.mode !== "tui" || this.stopping || !this.rosterDetached || !this.server || !this.verifiedOwner()) throw new Error("Pi roster return has no verified live TUI owner");
|
|
440
|
-
|
|
456
|
+
if (!this.fencedPrompt) throw new Error("Pi roster return needs a fenced-prompt extension; restart the retained terminal with the current hub extension");
|
|
457
|
+
if (this.pendingPromptId) throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
|
|
458
|
+
const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
|
|
441
459
|
const snapshot = await this.sendTui({ type: "get_session_state" });
|
|
442
460
|
if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean") throw new Error("Pi roster return could not verify the current native session");
|
|
461
|
+
if (lifecycle !== this.rosterLifecycleRevision) throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
|
|
443
462
|
this.rosterDetached = false;
|
|
463
|
+
this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
|
|
444
464
|
this.rosterUnownedRun = !snapshot.idle; this.agentRunning = !snapshot.idle;
|
|
445
465
|
this.setState(snapshot.idle ? "idle" : "busy");
|
|
446
466
|
if (!snapshot.idle) this.clearWatchdog();
|
|
@@ -534,6 +554,7 @@ export class PiPeer extends BasePeer {
|
|
|
534
554
|
this.setState("busy"); this.activeEnvs = envs; this.currentReply = replyParent(envs); this.settledText = "";
|
|
535
555
|
const rosterEpoch = this.rosterEpoch;
|
|
536
556
|
let attempted = false;
|
|
557
|
+
let promptId: string | undefined;
|
|
537
558
|
try {
|
|
538
559
|
const requested = await this.opts.selectModel?.(envs);
|
|
539
560
|
if (rosterEpoch !== this.rosterEpoch || !this.acceptingTools) throw new Error("Pi is off roster or stopped");
|
|
@@ -542,10 +563,32 @@ export class PiPeer extends BasePeer {
|
|
|
542
563
|
this.noteActivity();
|
|
543
564
|
attempted = true;
|
|
544
565
|
if (deliveryId) this.activeDeliveryIds.add(deliveryId);
|
|
545
|
-
if (this.opts.mode === "tui")
|
|
566
|
+
if (this.opts.mode === "tui") { const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) }); promptId = this.pendingPromptId; await pending; }
|
|
546
567
|
else await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
|
|
568
|
+
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
569
|
+
if (this.fencedPrompt && this.rosterDetached) {
|
|
570
|
+
if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi native activity changed before prompt acceptance was confirmed" });
|
|
571
|
+
this.setState("offline");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
547
574
|
if (deliveryId && this.activeDeliveryIds.has(deliveryId)) this.delivery({ id: deliveryId, state: "accepted" });
|
|
548
575
|
} catch (error) {
|
|
576
|
+
if (promptId !== undefined && promptId !== this.pendingPromptId) return;
|
|
577
|
+
if (error instanceof PiPromptUncertain) { this.pendingPromptId = undefined; await this.detachForRoster(); return; }
|
|
578
|
+
if (error instanceof PiPromptNotStarted && error.promptId === this.pendingPromptId) {
|
|
579
|
+
this.pendingPromptId = undefined;
|
|
580
|
+
if (deliveryId) this.activeDeliveryIds.delete(deliveryId);
|
|
581
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: error.message });
|
|
582
|
+
else this.onFailed?.(envs);
|
|
583
|
+
await this.detachForRoster();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (this.fencedPrompt && this.pendingPromptId && this.rosterDetached) {
|
|
587
|
+
this.pendingPromptId = undefined;
|
|
588
|
+
if (deliveryId && this.activeDeliveryIds.delete(deliveryId)) this.delivery({ id: deliveryId, state: "needs_review", reason: "Pi prompt acknowledgement is uncertain after personal native activity" });
|
|
589
|
+
this.setState("offline");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
549
592
|
if (rosterEpoch !== this.rosterEpoch) {
|
|
550
593
|
if (!attempted && deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: "Pi withdrawn before native dispatch" });
|
|
551
594
|
return;
|
|
@@ -563,11 +606,13 @@ export class PiPeer extends BasePeer {
|
|
|
563
606
|
this.delivery({ id: deliveryId, state: "failed_safe", reason });
|
|
564
607
|
}
|
|
565
608
|
await this.terminateFailedTurn(error instanceof Error ? error : new Error(reason));
|
|
609
|
+
} finally {
|
|
610
|
+
if (promptId !== undefined && this.pendingPromptId === promptId) this.pendingPromptId = undefined;
|
|
566
611
|
}
|
|
567
612
|
}
|
|
568
613
|
|
|
569
614
|
async steer(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
570
|
-
if (this.state !== "busy" || !this.acceptingTools || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
|
|
615
|
+
if (this.state !== "busy" || !this.acceptingTools || (this.fencedPrompt && this.pendingPromptId && this.hubPromptGeneration === undefined) || envs.some((e) => e.kind !== "chat" || e.private || e.priority !== "important")) {
|
|
571
616
|
const reason = `Pi workflow/private messages must queue${this.rosterDetached ? ": off roster" : this.rosterUnownedRun ? ": native turn pending" : ""}`;
|
|
572
617
|
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason });
|
|
573
618
|
throw new Error(reason);
|
|
@@ -597,14 +642,39 @@ export class PiPeer extends BasePeer {
|
|
|
597
642
|
}
|
|
598
643
|
|
|
599
644
|
private handleBridgeEvent(event: any): void {
|
|
645
|
+
if (this.fencedPrompt && this.opts.mode === "tui" && !this.rosterUnownedRun && ["agent_start", "agent_end", "agent_settled"].includes(event.type)
|
|
646
|
+
&& typeof event.promptId === "string" && event.promptId !== this.hubPromptId) return;
|
|
647
|
+
if (this.fencedPrompt && this.opts.mode === "tui" && (this.activeEnvs.length > 0 || this.pendingPromptId || this.hubPromptId) && !this.rosterDetached && !this.rosterUnownedRun && !this.stopping && ["agent_start", "agent_end", "agent_settled"].includes(event.type)) {
|
|
648
|
+
const bound = this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
|
|
649
|
+
if (!bound || !Number.isSafeInteger(event.generation) || event.generation < this.budgetGeneration) return;
|
|
650
|
+
const owned = this.hubPromptId !== undefined && event.promptId === this.hubPromptId;
|
|
651
|
+
if (event.type === "agent_start" && event.generation > this.budgetGeneration) {
|
|
652
|
+
if (owned) this.hubPromptGeneration = event.generation;
|
|
653
|
+
else {
|
|
654
|
+
if (this.pendingPromptId || this.activeEnvs.length) {
|
|
655
|
+
// Await the prompt's exact ACK before deciding whether its effect was absent or uncertain.
|
|
656
|
+
this.rosterDetached = true; this.rosterEpoch++; this.clearWatchdog();
|
|
657
|
+
this.activeEnvs = []; this.currentReply = undefined; this.settledText = ""; this.settledError = "";
|
|
658
|
+
this.abortTools();
|
|
659
|
+
if (!this.pendingPromptId) {
|
|
660
|
+
for (const id of this.activeDeliveryIds) this.delivery({ id, state: "needs_review", reason: "Pi personal native activity replaced the accepted hub turn" });
|
|
661
|
+
this.activeDeliveryIds.clear(); this.setState("offline");
|
|
662
|
+
}
|
|
663
|
+
} else { this.rosterUnownedRun = true; this.setState("busy"); this.clearWatchdog(); }
|
|
664
|
+
}
|
|
665
|
+
} else if (!this.rosterUnownedRun && (!owned || event.generation !== this.hubPromptGeneration)) return;
|
|
666
|
+
}
|
|
600
667
|
if (this.rosterDetached || this.rosterUnownedRun) {
|
|
601
668
|
// The shipped extension's legacy agent_start omits sessionId. Its authenticated bridge
|
|
602
669
|
// and still-verified owner bind that event to this session; an explicit mismatch never does.
|
|
603
670
|
const bound = !this.stopping && this.verifiedOwner() && (event.sessionId === undefined || event.sessionId === this.sessionId);
|
|
604
671
|
if (bound && event.type === "agent_start" && Number.isSafeInteger(event.generation) && event.generation > this.budgetGeneration) {
|
|
672
|
+
this.rosterLifecycleRevision++;
|
|
605
673
|
this.budgetGeneration = event.generation;
|
|
606
674
|
this.noteActivity(); // personal native activity also revokes an earlier ID-only empty-resume proof
|
|
607
675
|
}
|
|
676
|
+
// Snapshot ACKs and lifecycle events use separate requests, so their arrival order is not native state order.
|
|
677
|
+
if (bound && event.type === "agent_settled" && (event.generation === undefined || event.generation === this.budgetGeneration)) this.rosterLifecycleRevision++;
|
|
608
678
|
if (!bound && event.type !== "session_shutdown") return;
|
|
609
679
|
}
|
|
610
680
|
if (this.rosterDetached && event.type !== "session_shutdown") return;
|
|
@@ -619,6 +689,7 @@ export class PiPeer extends BasePeer {
|
|
|
619
689
|
if (event.type === "session_start") {
|
|
620
690
|
if (!this.ownerClaimed) { this.ownerClaimed = true; this.ownerToken = String(event.ownerToken ?? ""); }
|
|
621
691
|
this.approvalTurnAbort = event.approvalTurnAbort === true;
|
|
692
|
+
this.fencedPrompt ||= event.fencedPrompt === true;
|
|
622
693
|
this.sessionId = String(event.sessionId ?? ""); this.sessionFile = String(event.sessionFile ?? "");
|
|
623
694
|
this.ownerPid = Number.isInteger(event.pid) ? event.pid : undefined; this.ownerSignature = typeof event.signature === "string" ? event.signature : undefined;
|
|
624
695
|
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; }
|
|
@@ -699,6 +770,7 @@ export class PiPeer extends BasePeer {
|
|
|
699
770
|
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" } : {}) });
|
|
700
771
|
this.activeDeliveryIds.clear();
|
|
701
772
|
this.currentReply = undefined; this.activeEnvs = [];
|
|
773
|
+
this.hubPromptId = undefined; this.hubPromptGeneration = undefined;
|
|
702
774
|
this.executionAbort?.abort(); this.executionAbort = undefined;
|
|
703
775
|
this.approvalExpiries = 0; this.approvalAnswerEpoch = undefined; this.approvalStopReason = ""; this.approvalAbortSent = false;
|
|
704
776
|
this.abortedToolIds.clear(); this.abortIdsOverflow = false;
|
|
@@ -717,7 +789,9 @@ export class PiPeer extends BasePeer {
|
|
|
717
789
|
}
|
|
718
790
|
private sendTui(command: Record<string, unknown>): Promise<any> {
|
|
719
791
|
const id = `ahub-${++this.seq}`;
|
|
720
|
-
const
|
|
792
|
+
const fenced = this.fencedPrompt && command.type === "prompt";
|
|
793
|
+
if (fenced) { this.pendingPromptId = id; this.hubPromptId = id; this.hubPromptGeneration = undefined; }
|
|
794
|
+
const wire = { id, ...command, ...(fenced ? { sessionId: this.sessionId, generation: this.budgetGeneration } : {}) };
|
|
721
795
|
return new Promise((resolvePromise, reject) => {
|
|
722
796
|
const timer = setTimeout(() => {
|
|
723
797
|
this.tuiCommands.delete(id);
|
package/src/cli/help.ts
CHANGED
|
@@ -110,7 +110,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
110
110
|
["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
|
|
111
111
|
["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
|
|
112
112
|
["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
|
|
113
|
-
["ahub memory import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
|
|
113
|
+
["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
|
|
114
114
|
["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
|
|
115
115
|
["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks; take --force voids other machines' claims"],
|
|
116
116
|
]],
|
package/src/cli/identity.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
|
|
|
61
61
|
if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
|
|
62
62
|
if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
|
|
63
63
|
const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
|
|
64
|
-
if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
|
|
64
|
+
if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
|
|
65
65
|
}
|
|
66
66
|
return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
|
|
67
67
|
}
|
package/src/cli/memory.ts
CHANGED
|
@@ -12,7 +12,7 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
|
|
|
12
12
|
* #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
|
|
13
13
|
* supersede and rebuild are a person's (the identity gate refuses agent shells).
|
|
14
14
|
*/
|
|
15
|
-
export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <suggestion id> [--json]";
|
|
15
|
+
export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]";
|
|
16
16
|
export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
|
|
17
17
|
|
|
18
18
|
const fail = (message: string): never => { throw new Error(message); };
|
|
@@ -85,17 +85,27 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
85
85
|
if (!r.ok) fail(`hub memory sync: ${r.error}`);
|
|
86
86
|
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
87
87
|
}
|
|
88
|
+
case "dedupe": {
|
|
89
|
+
const apply = rest.includes("--apply");
|
|
90
|
+
if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
|
|
91
|
+
const result = store.dedupe(project, apply);
|
|
92
|
+
if (json) return JSON.stringify(result, null, 2);
|
|
93
|
+
return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
|
|
94
|
+
...result.groups.map(g => `${g.members.map(v => v.id.slice(0, 8)).join(", ")} -> ${g.target.slice(0, 8)}: ${g.members[0]!.text}`)].join("\n");
|
|
95
|
+
}
|
|
88
96
|
case "import": {
|
|
89
97
|
const db = flag("--db"), limit = flag("--limit");
|
|
90
|
-
|
|
98
|
+
const restart = rest.includes("--restart");
|
|
99
|
+
if (restart) rest.splice(rest.indexOf("--restart"), 1);
|
|
100
|
+
if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
|
|
91
101
|
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
92
102
|
const routing = currentRouting(cwd);
|
|
93
103
|
const policy = privatePaths(); // a note about a private path never reaches the synced log
|
|
94
|
-
const key = memoryProjectKey(cwd)
|
|
95
|
-
const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key,
|
|
96
|
-
const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
|
|
104
|
+
const key = memoryProjectKey(cwd);
|
|
105
|
+
const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
|
|
106
|
+
const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
|
|
97
107
|
if (json) return JSON.stringify(r, null, 2);
|
|
98
|
-
return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.
|
|
108
|
+
return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; other candidates were empty, malformed or already imported)${r.more ? "\nmore remain: run it again" : ""}`;
|
|
99
109
|
}
|
|
100
110
|
case "suggestions": {
|
|
101
111
|
const pending = store.suggestions(project).filter((x) => x.status === "suggested");
|
package/src/hub/daemon.ts
CHANGED
|
@@ -419,6 +419,8 @@ interface Client {
|
|
|
419
419
|
peer?: PeerId;
|
|
420
420
|
tail?: () => void;
|
|
421
421
|
watch?: () => void;
|
|
422
|
+
/** The one-launch Kimi TUI admission this socket attached under; its close clears only that admission. */
|
|
423
|
+
kimiAdmission?: string;
|
|
422
424
|
}
|
|
423
425
|
type Sock = ServerWebSocket<Client>;
|
|
424
426
|
|
|
@@ -655,7 +657,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
655
657
|
const peer = sock.data.peer ? bus.peers.get(sock.data.peer) : undefined;
|
|
656
658
|
if (peer instanceof WsPeer) {
|
|
657
659
|
peer.detach(sock);
|
|
658
|
-
if (sock.data.peer === "kimi" && peer.state === "offline" && !peer.claiming) clearKimiAdmission();
|
|
660
|
+
if (sock.data.peer === "kimi" && peer.state === "offline" && !peer.claiming && sock.data.kimiAdmission === kimiAdmissionId) clearKimiAdmission();
|
|
659
661
|
}
|
|
660
662
|
},
|
|
661
663
|
},
|
|
@@ -3869,7 +3871,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3869
3871
|
preparedSessionRoles.delete(c.peer); // an external transport has no receipt for this daemon's native preamble
|
|
3870
3872
|
preparedSessionRoleIdentities.delete(c.peer);
|
|
3871
3873
|
ws.claim(sock, msg.channel === false);
|
|
3872
|
-
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; }
|
|
3874
|
+
if (kimiPull) { clearTimeout(kimiAdmissionExpiry); kimiAdmissionExpiry = undefined; c.kimiAdmission = kimiAdmissionId; }
|
|
3873
3875
|
if (c.peer === "claude") logPermissionStart(c.peer);
|
|
3874
3876
|
writeStatus(); // the claim has to be visible before the preface, or a standing-by session takes the id back
|
|
3875
3877
|
void ensurePreface(c.peer).finally(() => {
|
|
@@ -4154,7 +4156,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4154
4156
|
if (c.role !== "console") return void reply({ ok: false, error: "watch is a person-only console command" });
|
|
4155
4157
|
if (msg.peer !== "kimi" && msg.peer !== "qwen") return void reply({ ok: false, error: "watch supports headless kimi and qwen only" });
|
|
4156
4158
|
const peer = msg.peer as "kimi" | "qwen";
|
|
4157
|
-
|
|
4159
|
+
// Judged by the live owner: a headless Kimi started by --mode headless is watchable under a TUI start mode.
|
|
4160
|
+
const kimiOwner = bus.peers.get("kimi");
|
|
4161
|
+
if (peer === "kimi" && !(kimiOwner instanceof AcpPeer) && (kimiOwner instanceof WsPeer || (kimiSessionMode ?? startMode("kimi")) === "tui")) return void reply({ ok: false, error: "Kimi is in pull-only TUI mode; use its native terminal or start headless" });
|
|
4158
4162
|
if (!membershipAllowed(peer)) return void reply({ ok: false, error: `${peer} is off roster` });
|
|
4159
4163
|
c.watch?.();
|
|
4160
4164
|
c.watch = activityRings[peer].subscribe(item => {
|
package/src/hub/path-policy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { lstatSync, opendirSync, statSync } from "node:fs";
|
|
1
|
+
import { accessSync, constants, lstatSync, opendirSync, readlinkSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { realPath } from "./project.ts";
|
|
4
|
+
import { foldSegment } from "./permission-mode.ts";
|
|
4
5
|
/** Bounded project-relative exact paths/subtrees or *, ? and whole-segment ** globs. */
|
|
5
6
|
export function policyPaths(raw: unknown, field: string): string[] {
|
|
6
7
|
if (raw === undefined) return [];
|
|
@@ -15,16 +16,68 @@ export function policyRealPath(path: string): string {
|
|
|
15
16
|
while (!existing(ancestor)) { const parent = dirname(ancestor); if (parent === ancestor) throw new Error("path cannot be resolved"); ancestor = parent; }
|
|
16
17
|
return join(realPath(ancestor), relative(ancestor, path));
|
|
17
18
|
}
|
|
18
|
-
const
|
|
19
|
+
const within = (path: string, root: string) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
20
|
+
// Either spelling protects, as for globs: lowercase as before, or folded like a case-insensitive disk opens names (NFD/NFC,
|
|
21
|
+
// compatibility forms, long s, ignorable code points). Folding is not a function of the lowercase form for every name.
|
|
22
|
+
const beneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase()) || within(foldSegment(path), foldSegment(root));
|
|
23
|
+
const lowerBeneath = (path: string, root: string) => within(path.toLowerCase(), root.toLowerCase());
|
|
19
24
|
export function policyTargets(cwd: string, paths: string[]): string[] {
|
|
20
25
|
const root = realPath(cwd);
|
|
21
26
|
return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
|
|
22
27
|
}
|
|
23
|
-
|
|
28
|
+
type DiscoveryBudget = { entries: number };
|
|
29
|
+
/** A dangling direct child names a future lexical target; an ELOOP child opens no file. */
|
|
30
|
+
function directAliasTarget(path: string): string | undefined {
|
|
31
|
+
let target = path;
|
|
32
|
+
// Include the final target resolution after the last bounded redirect.
|
|
33
|
+
for (let hops = 0; hops <= 40; hops++) {
|
|
34
|
+
try { return policyRealPath(target); }
|
|
35
|
+
catch (error) {
|
|
36
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
37
|
+
if (code === "ELOOP") return undefined;
|
|
38
|
+
if (code !== "ENOENT") throw error;
|
|
39
|
+
}
|
|
40
|
+
let ancestor = target;
|
|
41
|
+
while (!existing(ancestor)) {
|
|
42
|
+
const parent = dirname(ancestor);
|
|
43
|
+
if (parent === ancestor) return undefined;
|
|
44
|
+
ancestor = parent;
|
|
45
|
+
}
|
|
46
|
+
if (!lstatSync(ancestor).isSymbolicLink()) return undefined;
|
|
47
|
+
target = resolve(dirname(ancestor), readlinkSync(ancestor), relative(ancestor, target));
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Only a completed bounded scan contributes aliases; a truncated root contributes none. */
|
|
52
|
+
function aliasScope(dir: string, lexical: string, actual: string, includeParents: boolean, budget: DiscoveryBudget): boolean {
|
|
53
|
+
// Readability must not depend on reaching readSync after an exhausted-budget return.
|
|
54
|
+
accessSync(dir, constants.R_OK);
|
|
55
|
+
const handle = opendirSync(dir);
|
|
56
|
+
let matched = false;
|
|
57
|
+
try {
|
|
58
|
+
if (budget.entries >= 4096) return false;
|
|
59
|
+
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
|
|
60
|
+
if (budget.entries >= 4096) return false;
|
|
61
|
+
budget.entries++;
|
|
62
|
+
if (!entry.isSymbolicLink()) continue;
|
|
63
|
+
const target = directAliasTarget(join(dir, entry.name));
|
|
64
|
+
if (target !== undefined && (beneath(lexical, target) || beneath(actual, target) || includeParents && (beneath(target, lexical) || beneath(target, actual)))) matched = true;
|
|
65
|
+
}
|
|
66
|
+
} finally { handle.closeSync(); }
|
|
67
|
+
return matched;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Either spelling protects: the lowercase comparison keeps `?` matching one code point whose fold expands (ß, fi), and
|
|
71
|
+
* the folded one catches NFD/NFC, compatibility and long-s aliases. Adding a match only ever adds protection.
|
|
72
|
+
*/
|
|
24
73
|
function segmentMatches(pattern: string, name: string): boolean {
|
|
25
|
-
|
|
74
|
+
return wildcard(pattern.toLowerCase(), name.toLowerCase()) || wildcard(foldSegment(pattern), foldSegment(name));
|
|
75
|
+
}
|
|
76
|
+
/** Linear-space wildcard DP, avoiding regex backtracking on untrusted paths. */
|
|
77
|
+
function wildcard(pattern: string, name: string): boolean {
|
|
78
|
+
const chars = [...name];
|
|
26
79
|
let previous = new Array<boolean>(chars.length + 1).fill(false); previous[0] = true;
|
|
27
|
-
for (const char of pattern
|
|
80
|
+
for (const char of pattern) {
|
|
28
81
|
const next = new Array<boolean>(chars.length + 1).fill(false);
|
|
29
82
|
next[0] = char === "*" && previous[0]!;
|
|
30
83
|
for (let i = 1; i <= chars.length; i++) next[i] = char === "*" ? previous[i]! || next[i - 1]! : previous[i - 1]! && (char === "?" || char === chars[i - 1]);
|
|
@@ -57,23 +110,34 @@ function patternScope(pattern: string[], states: Set<number>, path: string, incl
|
|
|
57
110
|
return includeParents && states.size > 0; // this scope can contain a future protected descendant
|
|
58
111
|
}
|
|
59
112
|
/** Resolve wildcard directory aliases without trusting a cached expansion. Bound all filesystem discovery. */
|
|
60
|
-
function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget:
|
|
113
|
+
function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: DiscoveryBudget, roots: Set<string>, aliasDiscovery = false): boolean {
|
|
61
114
|
const pattern = path.split("/").filter(p => p && p !== ".");
|
|
62
115
|
const start = closure(pattern, new Set([0]));
|
|
63
116
|
if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
|
|
64
117
|
const visit = (dir: string, states: Set<number>, depth: number, ancestors: Set<string>): boolean => {
|
|
65
118
|
const real = policyRealPath(dir);
|
|
66
|
-
if (states.has(pattern.length))
|
|
119
|
+
if (states.has(pattern.length)) {
|
|
120
|
+
if (beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual))) return true;
|
|
121
|
+
// Defer new child aliases until every pre-existing policy match has had its discovery budget.
|
|
122
|
+
if (pattern.at(-1) === "**" && existing(real) && statSync(real).isDirectory()) roots.add(real);
|
|
123
|
+
// An earlier `**` can still match deeper: an alias below this match (`ss/ß -> other` for `**/ß`) is searched too.
|
|
124
|
+
// A trailing `**` only re-matches everything below, so it never walks the subtree (as before, for paths outside it).
|
|
125
|
+
states = new Set([...states].filter(i => i !== pattern.length && !(i === pattern.length - 1 && pattern[i] === "**")));
|
|
126
|
+
if (!states.size) return false;
|
|
127
|
+
}
|
|
67
128
|
if (patternScope(pattern, states, relative(real, actual), includeParents)) return true;
|
|
68
129
|
if (!existing(dir)) return false;
|
|
69
|
-
|
|
130
|
+
// Unfolded: a sibling whose folded spelling looks like the project is outside it, and discovery there fails closed.
|
|
131
|
+
if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
|
|
70
132
|
const info = statSync(dir);
|
|
71
133
|
if (!info.isDirectory()) return false;
|
|
134
|
+
if (aliasDiscovery && budget.entries >= 4096) return false;
|
|
72
135
|
if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
|
|
73
136
|
const chain = new Set(ancestors); chain.add(real);
|
|
74
137
|
const handle = opendirSync(dir);
|
|
75
138
|
try {
|
|
76
139
|
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
|
|
140
|
+
if (aliasDiscovery && budget.entries >= 4096) return false;
|
|
77
141
|
if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
|
|
78
142
|
const next = advance(pattern, states, entry.name);
|
|
79
143
|
if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
|
|
@@ -83,14 +147,28 @@ function globScope(root: string, lexical: string, actual: string, path: string,
|
|
|
83
147
|
};
|
|
84
148
|
return visit(root, start, 0, new Set());
|
|
85
149
|
}
|
|
86
|
-
/** Compare declared spelling, real targets and
|
|
150
|
+
/** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
|
|
87
151
|
export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
|
|
88
152
|
if (!paths.length) return false;
|
|
89
153
|
try {
|
|
90
154
|
if (path.length > 4096) return true;
|
|
91
155
|
const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
|
|
92
156
|
includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
|
|
93
|
-
const budget = { entries: 0 };
|
|
94
|
-
|
|
157
|
+
const budget = { entries: 0 }, roots = new Set<string>(), exact: string[] = [];
|
|
158
|
+
if (paths.some(entry => {
|
|
159
|
+
if (/[?*]/.test(entry)) return globScope(root, lexical, actual, entry, includeParents, budget, roots);
|
|
160
|
+
const targets = policyTargets(root, [entry]);
|
|
161
|
+
if (targets.some(target => beneath(lexical, target) || beneath(actual, target) || includeParents && (beneath(target, lexical) || beneath(target, actual)))) return true;
|
|
162
|
+
const real = targets.at(-1)!;
|
|
163
|
+
if (existing(real) && statSync(real).isDirectory()) roots.add(real);
|
|
164
|
+
exact.push(entry);
|
|
165
|
+
return false;
|
|
166
|
+
})) return true;
|
|
167
|
+
// New exact-root discovery cannot consume the budget before legacy wildcard matches are checked.
|
|
168
|
+
for (const entry of exact) {
|
|
169
|
+
if (globScope(root, lexical, actual, `${entry}/**`, includeParents, budget, roots, true)) return true;
|
|
170
|
+
}
|
|
171
|
+
for (const dir of roots) if (aliasScope(dir, lexical, actual, includeParents, budget)) return true;
|
|
172
|
+
return false;
|
|
95
173
|
} catch { return true; }
|
|
96
174
|
}
|