@staix/agent-hub 0.12.22 → 0.12.23
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 +19 -0
- package/README.md +16 -8
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +3 -0
- package/docs/agent-notes/budget.md +4 -0
- package/docs/agent-notes/daemon.md +8 -1
- package/docs/agent-notes/local-worker.md +3 -2
- package/docs/agent-notes/tasks.md +2 -1
- package/docs/agent-notes/tests.md +2 -0
- package/docs/events.md +7 -3
- package/docs/operations.md +232 -26
- package/docs/quickstart.md +9 -4
- package/docs/security.md +15 -6
- package/docs/smoke.md +72 -5
- package/docs/specs/2026-09-19-agent-hub-design.md +77 -10
- package/docs/verification/2026-10-10-cli-output-phase1.md +309 -0
- package/docs/verification/2026-10-10-cli-output-phase2.md +672 -0
- package/docs/verification/2026-10-11-0.12.23.md +90 -0
- package/docs/verified.json +25 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +14 -3
- package/src/adapters/acp-quota.ts +13 -0
- package/src/adapters/acp.ts +95 -13
- package/src/adapters/codex-appserver.ts +43 -13
- package/src/cli/bench.ts +2 -1
- package/src/cli/console-state.ts +28 -22
- package/src/cli/console.ts +20 -10
- package/src/cli/help.ts +19 -14
- package/src/cli/identity.ts +2 -2
- package/src/cli/init.ts +1 -1
- package/src/cli/main.ts +272 -159
- package/src/cli/output.ts +363 -0
- package/src/cli/preview.ts +2 -1
- package/src/cli/reset.ts +3 -0
- package/src/cli/status-lines.ts +0 -28
- package/src/cli/terminal-recovery.ts +27 -1
- package/src/cli/upgrade-runtime.ts +24 -7
- package/src/cli/upgrade.ts +23 -5
- package/src/hub/board.ts +2 -0
- package/src/hub/budget.ts +39 -7
- package/src/hub/bus.ts +10 -11
- package/src/hub/child-process.ts +11 -0
- package/src/hub/conductor.ts +23 -11
- package/src/hub/config-trust.ts +16 -5
- package/src/hub/conflicts.ts +2 -0
- package/src/hub/context-window.ts +8 -1
- package/src/hub/crash.ts +2 -1
- package/src/hub/daemon.ts +338 -23
- package/src/hub/db-timeout.ts +3 -0
- package/src/hub/delivery-journal.ts +2 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +2 -0
- package/src/hub/execution-budget.ts +2 -0
- package/src/hub/facts.ts +5 -4
- package/src/hub/git.ts +32 -0
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/lifecycle.ts +10 -4
- package/src/hub/manager.ts +18 -5
- package/src/hub/peers.ts +5 -3
- package/src/hub/permission-mode.ts +14 -0
- package/src/hub/project.ts +2 -1
- package/src/hub/recovery-error.ts +31 -0
- package/src/hub/recovery-store.ts +6 -5
- package/src/hub/registry.ts +2 -1
- package/src/hub/routing.ts +114 -13
- package/src/hub/settings.ts +392 -0
- package/src/hub/snapshots.ts +4 -2
- package/src/hub/start-mode.ts +48 -0
- package/src/hub/tasks.ts +129 -31
- package/src/hub/terminal-open.ts +64 -0
- package/src/hub/ui.ts +25 -13
- package/src/local/deny.ts +29 -7
- package/src/local/sandbox.ts +44 -6
- package/src/memory/recall.ts +2 -2
- package/src/models/mlx.ts +2 -1
- package/src/pi/tool-receipts.ts +2 -1
- package/src/ui/index.html +157 -10
package/src/hub/tasks.ts
CHANGED
|
@@ -92,6 +92,24 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
|
92
92
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
93
93
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed", "idle sweep"]);
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Thrown when an assignment's board write is saved but a publish or notify after it failed (#297): the move is
|
|
97
|
+
* fact, the delivery is unconfirmed. Callers decide by this type, never by comparing owners or reading history.
|
|
98
|
+
* It stays a plain Error subclass, so the preEffect rule and the #231 ready-offer contract are unchanged.
|
|
99
|
+
* `task` and `cause` are non-enumerable: a PII task's title and detail must not reach a JSON.stringify of the error.
|
|
100
|
+
*/
|
|
101
|
+
export class AssignmentUndeliveredError extends Error {
|
|
102
|
+
/** The task as the board has it after the write. */
|
|
103
|
+
declare readonly task: Task;
|
|
104
|
+
declare readonly cause: unknown;
|
|
105
|
+
constructor(task: Task, cause: unknown) {
|
|
106
|
+
super(`task #${task.id}: the assignment to ${task.owner} is saved, but its delivery is not confirmed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
107
|
+
this.name = "AssignmentUndeliveredError";
|
|
108
|
+
Object.defineProperty(this, "task", { value: task, enumerable: false });
|
|
109
|
+
Object.defineProperty(this, "cause", { value: cause, enumerable: false });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
95
113
|
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
96
114
|
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
97
115
|
|
|
@@ -242,7 +260,13 @@ export class Tasks {
|
|
|
242
260
|
this.passedOver(task, a);
|
|
243
261
|
this.d.notify(`${message} ${reviewer ? "Reviewer" : "Owner"} reassignment suggestion: ${candidate ?? "no eligible peer"}.`);
|
|
244
262
|
if (!reviewer && candidate && this.sweepConfig.auto_reassign && this.sweepAvailable(candidate)) {
|
|
245
|
-
|
|
263
|
+
try {
|
|
264
|
+
await this.assignOwner(this.d.board.get(task.id)!, HUB, { candidates: [candidate], exclude: [responsible], event: "reassigned", reason: "idle", note: `idle sweep: ${finding.kind}; from ${responsible}` });
|
|
265
|
+
} catch (e) {
|
|
266
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown, and
|
|
267
|
+
// the sweep goes on (#297).
|
|
268
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
269
|
+
}
|
|
246
270
|
}
|
|
247
271
|
}
|
|
248
272
|
}
|
|
@@ -569,7 +593,12 @@ export class Tasks {
|
|
|
569
593
|
this.d.notify(`task ${this.publicTitle(lifted)}: the PII screen cleared it on a second look; it is routed as usual`);
|
|
570
594
|
const still = this.waitsFor(lifted);
|
|
571
595
|
if (still.length) return void this.d.board.update(lifted.id, HUB, "blocked", {}, `waits for ${still.map((id) => `#${id}`).join(", ")}`);
|
|
572
|
-
|
|
596
|
+
try {
|
|
597
|
+
await this.assignOwner(lifted, HUB);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown (#297).
|
|
600
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
601
|
+
}
|
|
573
602
|
}
|
|
574
603
|
|
|
575
604
|
/**
|
|
@@ -757,7 +786,12 @@ export class Tasks {
|
|
|
757
786
|
// The gone owner hears it on its next delivery, if it comes back mid-work.
|
|
758
787
|
if (next.owner && next.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${next.owner} while you were offline; stop working on it`));
|
|
759
788
|
} catch (e) {
|
|
760
|
-
|
|
789
|
+
if (e instanceof AssignmentUndeliveredError) {
|
|
790
|
+
// The move is fact for the board, so it is fact here too: record it, and tell the gone owner to stop
|
|
791
|
+
// (the console notice was printed where the error was thrown) (#297).
|
|
792
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner });
|
|
793
|
+
if (e.task.owner && e.task.owner !== peer) this.d.tell?.(peer, noteLine(HUB, "decision", `task #${task.id} moved to ${e.task.owner} while you were offline; stop working on it`));
|
|
794
|
+
} else this.d.notify(`task ${this.publicTitle(task)}: could not be released from ${peer}: ${(e as Error).message}`);
|
|
761
795
|
}
|
|
762
796
|
}
|
|
763
797
|
} finally {
|
|
@@ -766,9 +800,8 @@ export class Tasks {
|
|
|
766
800
|
return moved;
|
|
767
801
|
}
|
|
768
802
|
|
|
769
|
-
/** Same code as assignment, without doing it. */
|
|
770
|
-
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
771
|
-
const routing = this.d.routing();
|
|
803
|
+
/** Same code as assignment, without doing it. `routing`: a candidate policy to explain under (the settings preview, #269). */
|
|
804
|
+
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing: Routing = this.d.routing()): string[] {
|
|
772
805
|
if (typeof target === "number") {
|
|
773
806
|
const task = this.d.board.get(target);
|
|
774
807
|
if (!task) throw new Error(`no task #${target}`);
|
|
@@ -784,8 +817,8 @@ export class Tasks {
|
|
|
784
817
|
}
|
|
785
818
|
|
|
786
819
|
/** Public read path: every generated line is screened, including profile/config-derived trace fields. */
|
|
787
|
-
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
788
|
-
return this.explain(target).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
820
|
+
publicExplain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }, routing?: Routing): string[] {
|
|
821
|
+
return this.explain(target, routing).map(line => this.nameable(line) ? line : "[routing explanation withheld: it matches a PII pattern]");
|
|
789
822
|
}
|
|
790
823
|
|
|
791
824
|
/**
|
|
@@ -801,7 +834,8 @@ export class Tasks {
|
|
|
801
834
|
*/
|
|
802
835
|
private passedOver(task: Task, a: Assignment): string {
|
|
803
836
|
if (!a.unreserved || task.reserved === task.owner || this.excluded(task).includes(task.reserved!)) return "";
|
|
804
|
-
|
|
837
|
+
// Before the write, but after the caller's own (a decline's, say): a throwing console fails nothing (#297).
|
|
838
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: its reserved owner ${task.reserved} is passed over (${a.unreserved}); routing proceeds`);
|
|
805
839
|
return `; reserved owner ${task.reserved} passed over: ${a.unreserved}`;
|
|
806
840
|
}
|
|
807
841
|
|
|
@@ -826,12 +860,12 @@ export class Tasks {
|
|
|
826
860
|
const a = assign(task, this.states(), this.d.routing(), { exclude: [...(opts.override ? [] : this.excluded(task)), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}), waitsFor: waits, ...this.weights(task.class) });
|
|
827
861
|
|
|
828
862
|
if (waits.length) {
|
|
829
|
-
this.
|
|
863
|
+
this.releaseNotice(`task ${this.publicTitle(task)} waits for ${waits.map((id) => `#${id}`).join(", ")}; it is offered once they are approved`);
|
|
830
864
|
return task;
|
|
831
865
|
}
|
|
832
866
|
const passed = this.passedOver(task, a);
|
|
833
867
|
if (!a.owner) {
|
|
834
|
-
this.
|
|
868
|
+
this.releaseNotice(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
835
869
|
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
836
870
|
if (opts.clearOnFail && task.owner) {
|
|
837
871
|
this.cohorts.leave(task.id);
|
|
@@ -856,19 +890,38 @@ export class Tasks {
|
|
|
856
890
|
// A claim is its own hand-over: the claimant took the task in the turn it is in, so it is taking it, not busy elsewhere.
|
|
857
891
|
// The cohort record formed just below reads it, and so does `route explain` while that turn lasts.
|
|
858
892
|
if (opts.claim && a.owner === by && this.d.bus.peers.get(by)?.state === "busy") this.sent.set(handOver(next, by), by);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
893
|
+
try {
|
|
894
|
+
const hits = this.overlapHits(next);
|
|
895
|
+
this.formCohort(next, hits);
|
|
896
|
+
if (hits.length) {
|
|
897
|
+
this.announceOverlap(next, hits);
|
|
898
|
+
this.tellEarlierOwners(next, hits);
|
|
899
|
+
}
|
|
900
|
+
this.announceRouting(next, a);
|
|
901
|
+
if (!opts.claim || a.owner !== by) await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
902
|
+
} catch (e) {
|
|
903
|
+
// Everything between the write and the end of the delivery is in here: the move is fact, and a failure is a
|
|
904
|
+
// delivery failure (#297). The one console notice is printed here, once, for every caller; callers decide
|
|
905
|
+
// control flow by the error type.
|
|
906
|
+
let saved = next;
|
|
907
|
+
try {
|
|
908
|
+
saved = this.d.board.get(task.id) ?? next;
|
|
909
|
+
} catch { /* the write's row is still true */ }
|
|
910
|
+
// Superseded means a later entry that moves or settles the task: another assignment, an unassign, the
|
|
911
|
+
// owner's accept, done, approval, close. Entries that do neither (the hub's quiet bookkeeping, a check
|
|
912
|
+
// result landing during the brief) leave the assignment exactly as undelivered as before: the notice and
|
|
913
|
+
// the typed error stand (#297 Decisions).
|
|
914
|
+
const moves = (entries: Task["history"]) => entries.filter((h) => !QUIET_EVENTS.has(h.event) && !h.event.startsWith("check "));
|
|
915
|
+
if (moves(saved.history).length !== moves(next.history).length) throw e;
|
|
916
|
+
this.releaseNotice(this.undeliveredNotice(saved, e));
|
|
917
|
+
throw new AssignmentUndeliveredError(saved, e);
|
|
864
918
|
}
|
|
865
|
-
|
|
919
|
+
// A claim is its own hand-over, with no delivery: its accepted write and its line stay as they were (#297).
|
|
866
920
|
if (opts.claim && a.owner === by) {
|
|
867
921
|
const claimed = this.d.board.update(next.id, by, "accepted", { state: "in_progress" });
|
|
868
|
-
this.
|
|
922
|
+
this.releaseNotice(`task ${this.publicTitle(claimed)} claimed by ${by}`);
|
|
869
923
|
return claimed;
|
|
870
924
|
}
|
|
871
|
-
await this.sendTask(next, a, opts.context, this.overlaps(next, true, hits), opts.contextWithheld);
|
|
872
925
|
return next;
|
|
873
926
|
}
|
|
874
927
|
|
|
@@ -936,6 +989,16 @@ export class Tasks {
|
|
|
936
989
|
}
|
|
937
990
|
}
|
|
938
991
|
|
|
992
|
+
/**
|
|
993
|
+
* The one text for a saved assignment whose delivery is unconfirmed (#297), built through publicTitle (a PII task
|
|
994
|
+
* shows nothing more than it does today): what to check, and how to send it again. No automatic resend: a blind
|
|
995
|
+
* resend can duplicate a publish that did leave (the #276 decision).
|
|
996
|
+
*/
|
|
997
|
+
undeliveredNotice(task: Task, cause: unknown): string {
|
|
998
|
+
const owner = task.owner ?? "its owner";
|
|
999
|
+
return `task ${this.publicTitle(task)}: the move to ${owner} is saved, but its assignment's delivery is not confirmed (${cause instanceof Error ? cause.message : String(cause)}); look for a queued or needs_review delivery with ahub queue list --peer ${owner} (then ahub queue show <id>), or the accepted entry in ahub task show ${task.id} if the journal is down; send it again with: ahub task assign ${task.id} ${owner}`;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
939
1002
|
private readonly offered = new Set<number>(); // assignments in this hub run, including offers still in flight
|
|
940
1003
|
private readonly sent = new Map<string, PeerId>(); // hand-over (`handOver`) -> owner, for the owner's current turn: its task envelope started it, or it claimed the task in it
|
|
941
1004
|
|
|
@@ -958,7 +1021,7 @@ export class Tasks {
|
|
|
958
1021
|
|
|
959
1022
|
/** Callers hold a list read before an await: re-read, or a task the other caller offered meanwhile is offered twice. */
|
|
960
1023
|
private async offerReady(stale: Task, why: string): Promise<void> {
|
|
961
|
-
let offering = false;
|
|
1024
|
+
let offering = false, undelivered: AssignmentUndeliveredError | undefined;
|
|
962
1025
|
try {
|
|
963
1026
|
const t = this.d.board.get(stale.id);
|
|
964
1027
|
if (!t || t.state !== "proposed" || t.owner || this.offered.has(t.id)) return;
|
|
@@ -968,9 +1031,12 @@ export class Tasks {
|
|
|
968
1031
|
this.releaseNotice(`task ${this.publicTitle(ready)} is ready: what it waited for is approved`);
|
|
969
1032
|
await this.assignOwner(ready, HUB);
|
|
970
1033
|
} catch (e) {
|
|
971
|
-
|
|
1034
|
+
// A saved move whose delivery failed is not "could not be assigned" (#297); the console notice was printed
|
|
1035
|
+
// where the error was thrown. The offer stays consumed by the error's own evidence, without the readback.
|
|
1036
|
+
if (e instanceof AssignmentUndeliveredError) undelivered = e;
|
|
1037
|
+
else this.releaseNotice(`task ${this.publicTitle(stale)}: could not be assigned: ${e instanceof Error ? e.message : String(e)}`);
|
|
972
1038
|
} finally {
|
|
973
|
-
if (offering) {
|
|
1039
|
+
if (offering && !undelivered) {
|
|
974
1040
|
try {
|
|
975
1041
|
if (!this.d.board.get(stale.id)?.owner) this.offered.delete(stale.id);
|
|
976
1042
|
} catch (e) {
|
|
@@ -1380,7 +1446,18 @@ export class Tasks {
|
|
|
1380
1446
|
this.contradict(rejected);
|
|
1381
1447
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`, withheld);
|
|
1382
1448
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
1383
|
-
|
|
1449
|
+
let moved: Task;
|
|
1450
|
+
try {
|
|
1451
|
+
moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`, "rejections");
|
|
1452
|
+
} catch (e) {
|
|
1453
|
+
// The verdict is saved. If the escalation's move was saved too, only its delivery is unconfirmed, and the
|
|
1454
|
+
// error carries the saved task (#297); the console notice was printed where the error was thrown. The
|
|
1455
|
+
// reviewer gets the saved task back, never a failed tool call (#276). Anything else (the task closed
|
|
1456
|
+
// meanwhile (#254), a failed board write, a superseded move) still throws. assignOwner keeps throwing for
|
|
1457
|
+
// the ready offer's retry (#231).
|
|
1458
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1459
|
+
return e.task;
|
|
1460
|
+
}
|
|
1384
1461
|
if (moved.owner !== rejected.owner) return moved;
|
|
1385
1462
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
1386
1463
|
this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${moved.owner ?? "its owner"} changes were requested again`, () =>
|
|
@@ -1404,13 +1481,27 @@ export class Tasks {
|
|
|
1404
1481
|
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
1405
1482
|
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
1406
1483
|
if (from && task.reviewer && task.reviewer !== USER && this.requestedChanges(task).has(task.reviewer)) this.d.board.recordReview({ implementer: from, reviewer: task.reviewer, class: task.class, kind: "escalated", task: task.id });
|
|
1407
|
-
|
|
1484
|
+
// The guarded tail of a landed move: the console notice, the decision note and the old owner's stop message,
|
|
1485
|
+
// each behind its own releaseNotice. It runs on the success path and on a saved move whose delivery failed.
|
|
1486
|
+
const tail = (moved: Task) => {
|
|
1487
|
+
this.releaseNotice(`task ${this.publicTitle(moved)} escalated from ${from} to ${moved.owner} (${why})`);
|
|
1488
|
+
this.note(moved, by, "decision", `Task #${moved.id} escalated from ${from} to ${moved.owner}: ${why}`);
|
|
1489
|
+
if (from) this.releaseNotice(`task ${this.publicTitle(moved)}: could not tell ${from} it moved to ${moved.owner}`, () =>
|
|
1490
|
+
this.tell({ ...moved, owner: from }, `Task #${moved.id} moved to ${moved.owner} (${why}). Stop working on it.`, this.isPii(moved)));
|
|
1491
|
+
};
|
|
1492
|
+
let next: Task;
|
|
1493
|
+
try {
|
|
1494
|
+
next = await this.assignOwner(task, by, { candidates: list, event: "escalated", reason, note: `${why}; from ${from ?? "none"}`, context: why });
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
// A saved move whose delivery failed arrives as AssignmentUndeliveredError carrying the saved task (#297);
|
|
1497
|
+
// the tail still runs on it (its parts are guarded), and the caller still gets the throw. Anything before
|
|
1498
|
+
// the write gets neither.
|
|
1499
|
+
if (e instanceof AssignmentUndeliveredError) tail(e.task);
|
|
1500
|
+
throw e;
|
|
1501
|
+
}
|
|
1408
1502
|
if (next.owner && next.owner !== from) {
|
|
1409
1503
|
// The move is on the board (#243): review() reaches here after its changes_requested write.
|
|
1410
|
-
|
|
1411
|
-
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
1412
|
-
if (from) this.releaseNotice(`task ${this.publicTitle(next)}: could not tell ${from} it moved to ${next.owner}`, () =>
|
|
1413
|
-
this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next)));
|
|
1504
|
+
tail(next);
|
|
1414
1505
|
} else this.releaseNotice(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
1415
1506
|
return this.d.board.get(next.id)!;
|
|
1416
1507
|
}
|
|
@@ -1421,7 +1512,7 @@ export class Tasks {
|
|
|
1421
1512
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
1422
1513
|
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], event: "reassigned", reason: "manual", override: by === USER });
|
|
1423
1514
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" });
|
|
1424
|
-
this.
|
|
1515
|
+
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
1425
1516
|
return next;
|
|
1426
1517
|
}
|
|
1427
1518
|
|
|
@@ -1442,8 +1533,15 @@ export class Tasks {
|
|
|
1442
1533
|
const pii = this.isPii(task);
|
|
1443
1534
|
const candidates = [pii ? LOCAL : PI, pii ? undefined : LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL && p !== PI)].filter((p): p is PeerId => !!p);
|
|
1444
1535
|
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
|
|
1445
|
-
|
|
1446
|
-
|
|
1536
|
+
try {
|
|
1537
|
+
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", reason: "budget", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context, contextWithheld } : {}) });
|
|
1538
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
1539
|
+
} catch (e) {
|
|
1540
|
+
// A saved move whose delivery failed: the console notice was printed where the error was thrown; the
|
|
1541
|
+
// task counts as moved and the relay goes on (#297). No retry is needed for it: the notice already spoke.
|
|
1542
|
+
if (!(e instanceof AssignmentUndeliveredError)) throw e;
|
|
1543
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: e.task.owner, role: "owner" });
|
|
1544
|
+
}
|
|
1447
1545
|
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
1448
1546
|
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
1449
1547
|
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}), ...this.weights(task.class) });
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { personEnv } from "./child-process.ts";
|
|
3
|
+
import { openStartTerminal, orcaExecutable, shellQuote, startTerminalWorktree, type CommandRunner } from "../cli/terminal-recovery.ts";
|
|
4
|
+
|
|
5
|
+
/** #269: where a terminal for a hub-made start comes from, or why there is none. */
|
|
6
|
+
export type TerminalOpened = { ok: true; via: string } | { ok: false; why: string; detail?: string };
|
|
7
|
+
export interface TerminalOpener {
|
|
8
|
+
/** Whether a start could open a terminal now; opens nothing. */
|
|
9
|
+
available(): TerminalOpened;
|
|
10
|
+
/** A terminal running `argv`, which the caller assembled from fixed words: nothing here adds to it. */
|
|
11
|
+
open(title: string, argv: readonly string[]): Promise<TerminalOpened>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ponytail: a template command still running after this long is taken to be the terminal itself, so one that fails
|
|
15
|
+
// later is reported as opened; a provider-specific readiness check (as recovery has for Orca) if that ever misleads.
|
|
16
|
+
const SETTLE_MS = 1500;
|
|
17
|
+
const PLACEHOLDER = /\{(command|title|cwd)\}/g;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The providers, in order: the machine-local `terminal.open` command template, then Orca (the path recovery uses)
|
|
21
|
+
* when this project has an Orca worktree. `run` replaces the Orca CLI in tests.
|
|
22
|
+
*/
|
|
23
|
+
export function terminalOpener(opts: { template: string[]; cwd: string; stateDir: string; env?: NodeJS.ProcessEnv; run?: CommandRunner }): TerminalOpener {
|
|
24
|
+
const env = opts.env ?? process.env;
|
|
25
|
+
const worktree = () => startTerminalWorktree(opts.cwd, opts.stateDir, env);
|
|
26
|
+
const available = (): TerminalOpened => {
|
|
27
|
+
if (opts.template.length) return { ok: true, via: "terminal.open" };
|
|
28
|
+
if (!worktree()) return { ok: false, why: "no terminal.open command is configured and this project has no Orca worktree on record" };
|
|
29
|
+
if (!opts.run && !Bun.which(orcaExecutable())) return { ok: false, why: `the ${orcaExecutable()} command is not on PATH` };
|
|
30
|
+
return { ok: true, via: "orca" };
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
available,
|
|
34
|
+
async open(title, argv) {
|
|
35
|
+
const can = available();
|
|
36
|
+
if (!can.ok) return can;
|
|
37
|
+
// One command line, each word quoted for a POSIX shell: the terminal's shell runs exactly these words.
|
|
38
|
+
const command = argv.map(shellQuote).join(" ");
|
|
39
|
+
try {
|
|
40
|
+
if (can.via === "orca") {
|
|
41
|
+
await openStartTerminal(worktree()!, command, title, opts.run ? { runner: opts.run } : undefined);
|
|
42
|
+
return can;
|
|
43
|
+
}
|
|
44
|
+
// One pass with a function: a placeholder inside a substituted value (a project path that contains "{cwd}")
|
|
45
|
+
// is not expanded again, and "$" in a path is not a replacement pattern. Only {command} is shell-quoted;
|
|
46
|
+
// {title} and {cwd} are raw text for an argv element of their own.
|
|
47
|
+
const values: Record<string, string> = { command, title, cwd: opts.cwd };
|
|
48
|
+
const [bin, ...args] = opts.template.map((part) => part.replace(PLACEHOLDER, (_, name: string) => values[name]!));
|
|
49
|
+
// Detached and unwatched: the terminal is the person's and outlives the hub.
|
|
50
|
+
const child = spawn(bin!, args, { cwd: opts.cwd, env: personEnv(env), stdio: "ignore", detached: true });
|
|
51
|
+
const failed = await new Promise<string | undefined>((resolve) => {
|
|
52
|
+
const timer = setTimeout(() => resolve(undefined), SETTLE_MS); // still running: it is the terminal itself
|
|
53
|
+
child.once("error", (error) => { clearTimeout(timer); resolve(`terminal.open could not start (${(error as NodeJS.ErrnoException).code ?? "error"})`); });
|
|
54
|
+
child.once("exit", (code, signal) => { clearTimeout(timer); resolve(code === 0 ? undefined : `terminal.open exited ${signal ?? code}`); });
|
|
55
|
+
});
|
|
56
|
+
child.unref();
|
|
57
|
+
return failed ? { ok: false, why: failed } : can;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
// The provider's own text can name paths and its stderr: it goes to the hub's log, not to the caller.
|
|
60
|
+
return { ok: false, why: can.via === "orca" ? "Orca could not create the terminal" : "terminal.open could not be run", detail: (error as Error).message };
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { PEER_START_RETRY_MS } from "./start-mode.ts";
|
|
1
2
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { readFileSync } from "node:fs";
|
|
4
5
|
|
|
6
|
+
/** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
|
|
7
|
+
export interface DashboardSession { settings: boolean; settingsUntil?: number }
|
|
5
8
|
export interface DashboardOptions {
|
|
6
|
-
snapshot: (after: number, input?: Record<string, unknown
|
|
7
|
-
action: (input: Record<string, unknown
|
|
9
|
+
snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
10
|
+
action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
8
11
|
projects?: () => unknown | Promise<unknown>;
|
|
9
12
|
selectedProjectId?: string;
|
|
10
13
|
/** Injectable clock for expiry tests; production uses wall time. */
|
|
@@ -13,17 +16,20 @@ export interface DashboardOptions {
|
|
|
13
16
|
|
|
14
17
|
const TICKET_MS = 60_000;
|
|
15
18
|
const SESSION_MS = 60 * 60_000;
|
|
19
|
+
/** How long a session opened by `ahub ui --settings` may change settings that raise authority; it stays an ordinary session after. */
|
|
20
|
+
export const SETTINGS_MS = 15 * 60_000;
|
|
16
21
|
const LIMIT = 64;
|
|
17
22
|
const secret = () => randomBytes(32).toString("hex");
|
|
18
23
|
|
|
19
24
|
/** Separate browser surface. Only the authenticated control console may construct it and issue tickets. */
|
|
20
25
|
export function startDashboard(options: DashboardOptions) {
|
|
21
26
|
const now = options.now ?? Date.now;
|
|
22
|
-
const tickets = new Map<string, number>();
|
|
23
|
-
const sessions = new Map<string, number>();
|
|
27
|
+
const tickets = new Map<string, { expires: number; settings: boolean }>();
|
|
28
|
+
const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
|
|
24
29
|
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
25
30
|
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
26
|
-
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
31
|
+
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
32
|
+
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`);
|
|
27
33
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
28
34
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
29
35
|
const headers = {
|
|
@@ -36,9 +42,13 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
36
42
|
const json = (body: unknown, status = 200, extra: Record<string, string> = {}) =>
|
|
37
43
|
new Response(JSON.stringify(body), { status, headers: { ...headers, "Content-Type": "application/json", ...extra } });
|
|
38
44
|
const reject = (status: number, error: string) => json({ ok: false, error }, status);
|
|
39
|
-
function prune(map: Map<string, number>) {
|
|
40
|
-
for (const [key,
|
|
45
|
+
function prune(map: Map<string, { expires: number }>) {
|
|
46
|
+
for (const [key, entry] of map) if (entry.expires <= now()) map.delete(key);
|
|
41
47
|
}
|
|
48
|
+
const sessionOf = (cookie: string | undefined): DashboardSession => {
|
|
49
|
+
const until = cookie ? sessions.get(cookie)?.settingsUntil : undefined;
|
|
50
|
+
return until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false };
|
|
51
|
+
};
|
|
42
52
|
const server = Bun.serve({
|
|
43
53
|
hostname: "127.0.0.1",
|
|
44
54
|
port: 0,
|
|
@@ -75,10 +85,11 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
75
85
|
if (path !== "/session" && (!cookie || !sessions.has(cookie))) return reject(401, "session expired; run ahub ui");
|
|
76
86
|
if (path === "/session") {
|
|
77
87
|
const ticket = typeof input.ticket === "string" ? input.ticket : "";
|
|
78
|
-
|
|
88
|
+
const issued = tickets.get(ticket);
|
|
89
|
+
if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
|
|
79
90
|
if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
|
|
80
91
|
const session = secret();
|
|
81
|
-
sessions.set(session, now() + SESSION_MS);
|
|
92
|
+
sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}) });
|
|
82
93
|
return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
|
|
83
94
|
}
|
|
84
95
|
if (path === "/projects") {
|
|
@@ -97,7 +108,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
97
108
|
const after = input.after ?? 0;
|
|
98
109
|
if (typeof after !== "number" || !Number.isSafeInteger(after) || after < 0) return reject(400, "invalid cursor");
|
|
99
110
|
try {
|
|
100
|
-
const result = await options.snapshot(after, input);
|
|
111
|
+
const result = await options.snapshot(after, input, sessionOf(cookie));
|
|
101
112
|
if (!result || typeof result !== "object" || Array.isArray(result)) return json(result);
|
|
102
113
|
const response = { ...(result as Record<string, unknown>) };
|
|
103
114
|
if (typeof response.projectId === "string" && typeof input.projectId === "string" && response.projectId !== input.projectId) return reject(409, "snapshot project changed; refresh the dashboard");
|
|
@@ -110,7 +121,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
123
|
try {
|
|
113
|
-
return json(await options.action(input));
|
|
124
|
+
return json(await options.action(input, sessionOf(cookie)));
|
|
114
125
|
} catch {
|
|
115
126
|
// Exceptions may quote private task text or backend details. Never echo them to the page.
|
|
116
127
|
return reject(400, "action failed; check its inputs and the terminal");
|
|
@@ -125,11 +136,12 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
125
136
|
const cookieName = `ahub_ui_${server.port}`;
|
|
126
137
|
return {
|
|
127
138
|
origin,
|
|
128
|
-
|
|
139
|
+
/** `settings`: the ticket of `ahub ui --settings`, whose session may raise authority for SETTINGS_MS (#269). */
|
|
140
|
+
issue(settings = false): string {
|
|
129
141
|
prune(tickets);
|
|
130
142
|
if (tickets.size >= LIMIT) tickets.delete(tickets.keys().next().value!);
|
|
131
143
|
const ticket = secret();
|
|
132
|
-
tickets.set(ticket, now() + TICKET_MS);
|
|
144
|
+
tickets.set(ticket, { expires: now() + TICKET_MS, settings });
|
|
133
145
|
return `${origin}/#${ticket}`;
|
|
134
146
|
},
|
|
135
147
|
stop() {
|
package/src/local/deny.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { basename, sep } from "node:path";
|
|
1
|
+
import { basename, dirname, sep } from "node:path";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
|
|
@@ -50,13 +50,23 @@ const hfsFoldedName = (name: string) => `${HFS_IGNORABLE}*${[...name].map((c) =>
|
|
|
50
50
|
* rename, symlink or gitfile, and `config` and `hooks` directly under it are folded too; the cost is that
|
|
51
51
|
* `git init`, `git clone` and `git worktree add` no longer run inside the sandbox. `commondir` is refused at any
|
|
52
52
|
* depth below a `.git` and below each external git dir: that is where git keeps it for linked worktrees and
|
|
53
|
-
* submodules.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* submodules. A submodule's git dir under `.git/modules/` gets the same `config`/`hooks` refusal at whatever depth
|
|
54
|
+
* submodules nest (a nested one lives under `.git/modules/<a>/modules/<b>`, #281), anchored at the discovered
|
|
55
|
+
* modules root so a directory named `refs`, `config` or `hooks` ABOVE the project neither disables the rule nor
|
|
56
|
+
* trips it; a `/refs/` or `/logs/` segment below the root keeps a branch or tag named `config` or `hooks` writable
|
|
57
|
+
* (the round-3 regression of #277), at the price of leaving a submodule whose own path holds a `refs` or `logs`
|
|
58
|
+
* segment unprotected. The basenames are folded; a folded INTERMEDIATE component on HFS+ (a spelling of `modules`)
|
|
59
|
+
* is a known residual, the same one the external git dir rules carry. The directory entries above those files are
|
|
60
|
+
* protected as the `.git` name is: the discovered git dirs, the modules root and every component in between are
|
|
61
|
+
* refused as targets (rename, replace, symlink, recreate). An external git dir's `config`/`hooks` rules cover only
|
|
62
|
+
* its own children (plus the hooks directory's contents): at any depth they would deny refs named `config` or
|
|
63
|
+
* `hooks` in a worktree or submodule project (`git branch fix/config` fails on `.git/logs/refs/heads/fix/config`).
|
|
64
|
+
* Every pattern keeps its `^` anchor (an unanchored starred alternation does not match in this engine) and no
|
|
65
|
+
* folded literal contains the root: an SBPL string literal dies at 1024 bytes ("Error reading string"), which a
|
|
66
|
+
* long project path would trip. ponytail: the modules rules do contain the (plain) modules root; a project path
|
|
67
|
+
* over roughly 600 bytes with submodules would exceed the limit — fold the root's segments if one ever shows up.
|
|
58
68
|
*/
|
|
59
|
-
export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[] {
|
|
69
|
+
export function hubWriteRegexes(root: string, gitDirs: string[] = [], moduleGit?: { root: string; entries: string[] }): string[] {
|
|
60
70
|
const git = hfsFoldedName(".git");
|
|
61
71
|
const re = (s: string) => `(regex ${sbplString(s)})`;
|
|
62
72
|
const external = gitDirs.flatMap((d) => [
|
|
@@ -64,12 +74,24 @@ export function hubWriteRegexes(root: string, gitDirs: string[] = []): string[]
|
|
|
64
74
|
`(subpath ${sbplString(`${d}/hooks`)})`,
|
|
65
75
|
`(require-all (subpath ${sbplString(d)}) ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
66
76
|
]);
|
|
77
|
+
const modules = moduleGit
|
|
78
|
+
? [
|
|
79
|
+
// Every name matches whole components only: `(.*/)?` forces a boundary, so a submodule named `catalogs`
|
|
80
|
+
// or `prefs` keeps the refusal and one named `webhooks` keeps its ordinary writes (#281 round 2).
|
|
81
|
+
`(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("config")}$`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
|
|
82
|
+
`(require-all ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?${hfsFoldedName("hooks")}(/|$)`)} (require-not ${re(`^${sbplEscape(moduleGit.root)}/(.*/)?(refs|logs)/`)}))`,
|
|
83
|
+
// A submodule git dir as a directory entry: no rename, replace, symlink or recreate. The basename is
|
|
84
|
+
// folded (HFS+ opens the folded spelling as the real entry); the plain prefix is the path as discovered.
|
|
85
|
+
...moduleGit.entries.map((d) => re(`^${sbplEscape(dirname(d))}/${hfsFoldedName(basename(d))}$`)),
|
|
86
|
+
]
|
|
87
|
+
: [];
|
|
67
88
|
return [
|
|
68
89
|
`(require-all (subpath ${sbplString(root)}) ${re(`^.*/${hfsFoldedName(".agenthub")}(/|$)`)})`,
|
|
69
90
|
re(`^.*/${git}$`),
|
|
70
91
|
re(`^.*/${git}/${hfsFoldedName("config")}$`),
|
|
71
92
|
re(`^.*/${git}/${hfsFoldedName("hooks")}(/|$)`),
|
|
72
93
|
`(require-all ${re(`^.*/${git}/.*$`)} ${re(`^.*/${hfsFoldedName("commondir")}$`)})`,
|
|
94
|
+
...modules,
|
|
73
95
|
...external,
|
|
74
96
|
];
|
|
75
97
|
}
|
package/src/local/sandbox.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, type Dirent } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { realPath } from "../hub/project.ts";
|
|
6
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
6
7
|
import { denyRegexes, hubWriteRegexes, sbplString } from "./deny.ts";
|
|
7
8
|
|
|
8
9
|
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
@@ -40,12 +41,47 @@ function developerDir(): string | undefined {
|
|
|
40
41
|
|
|
41
42
|
/** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
|
|
42
43
|
function externalGitDirs(root: string): string[] {
|
|
43
|
-
|
|
44
|
+
// Runs in the hub process, outside the sandbox: the hardened helper keeps the repo's config from naming a program (#281).
|
|
45
|
+
const out = hubGitSync(["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
|
|
44
46
|
if (out.status !== 0) return [];
|
|
45
47
|
const dirs = out.stdout.trim().split("\n").map((d) => resolve(root, d));
|
|
46
48
|
return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The project's submodule git directories live under the common dir's `modules/` tree (the worktree case included:
|
|
53
|
+
* it is the main repo's `.git/modules`). Returns the modules root and every directory entry that must not be
|
|
54
|
+
* renamed, replaced, symlinked or recreated: each discovered git dir (a directory holding HEAD and config) and
|
|
55
|
+
* every component between it and the modules root (#281). Discovered in the hub process, outside the sandbox.
|
|
56
|
+
*/
|
|
57
|
+
function moduleGitDirs(root: string): { root: string; entries: string[] } | undefined {
|
|
58
|
+
const out = hubGitSync(["-C", root, "rev-parse", "--git-common-dir"], { encoding: "utf8" });
|
|
59
|
+
if (out.status !== 0) return undefined;
|
|
60
|
+
const modulesRoot = join(resolve(root, out.stdout.trim()), "modules");
|
|
61
|
+
const dirs: string[] = [];
|
|
62
|
+
const walk = (dir: string, depth: number): void => {
|
|
63
|
+
if (depth > 8) return; // submodules do not nest deeper in practice; the regex rules still cover the names
|
|
64
|
+
let entries: Dirent[];
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
return; // no modules tree (no submodules): nothing to discover
|
|
69
|
+
}
|
|
70
|
+
const names = new Set(entries.map((e) => e.name));
|
|
71
|
+
if (names.has("HEAD") && names.has("config")) {
|
|
72
|
+
dirs.push(dir);
|
|
73
|
+
const nested = join(dir, "modules");
|
|
74
|
+
if (names.has("modules")) walk(nested, depth + 1); // a submodule of a submodule
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const e of entries) if (e.isDirectory()) walk(join(dir, e.name), depth + 1);
|
|
78
|
+
};
|
|
79
|
+
walk(modulesRoot, 0);
|
|
80
|
+
const entries = new Set<string>([modulesRoot]);
|
|
81
|
+
for (const d of dirs) for (let p = d; p.length > modulesRoot.length; p = dirname(p)) entries.add(p);
|
|
82
|
+
return { root: modulesRoot, entries: [...entries] };
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
/** The system directories a deny-default profile lets commands read and run from: dyld, frameworks, toolchains. */
|
|
50
86
|
const SYSTEM_READABLE = ["/usr", "/bin", "/sbin", "/System", "/Library", "/opt", "/private/etc", "/private/var/db/timezone", "/private/var/db/dyld"];
|
|
51
87
|
/**
|
|
@@ -74,6 +110,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
74
110
|
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
75
111
|
const root = realPath(cwd);
|
|
76
112
|
const gitDirs = externalGitDirs(root);
|
|
113
|
+
const moduleGit = moduleGitDirs(root);
|
|
77
114
|
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
78
115
|
const dev = developerDir();
|
|
79
116
|
const readable = [root, ...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs, ...(dev ? [dev] : [])];
|
|
@@ -104,9 +141,10 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
104
141
|
`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
|
|
105
142
|
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
|
|
106
143
|
// .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
|
|
107
|
-
// at any depth (creation, rename, symlink, gitfile),
|
|
108
|
-
//
|
|
109
|
-
|
|
144
|
+
// at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
|
|
145
|
+
// .git/modules/ are refused at the depth submodules nest (#281), and the external git dirs get folded
|
|
146
|
+
// hooks/config rules (#270). The cost: git init, clone and worktree add no longer run inside the sandbox.
|
|
147
|
+
`(deny file-write* ${hubWriteRegexes(root, gitDirs, moduleGit).join(" ")})`,
|
|
110
148
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
|
111
149
|
`(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
|
|
112
150
|
// Python's own CA bundle (certifi, which pip vendors too): pip, requests and httpx read it instead of the system's (#64).
|
package/src/memory/recall.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { basename, dirname, resolve } from "node:path";
|
|
3
2
|
import { readFileSync } from "node:fs";
|
|
4
3
|
import type { PeerId } from "../hub/envelope.ts";
|
|
4
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
5
5
|
import type { MemoryClient } from "./client.ts";
|
|
6
6
|
|
|
7
7
|
const PLATFORMS = ["claude", "codex", "kimi", "agent-hub"];
|
|
@@ -14,7 +14,7 @@ const HEADER =
|
|
|
14
14
|
|
|
15
15
|
/** Project names from the outermost superproject down to this repo. */
|
|
16
16
|
export function projectChain(cwd: string): string[] {
|
|
17
|
-
const git = (dir: string, arg: string) =>
|
|
17
|
+
const git = (dir: string, arg: string) => hubGitSync(["-C", dir, "rev-parse", arg], { encoding: "utf8" }).stdout?.trim() ?? "";
|
|
18
18
|
const top = git(cwd, "--show-toplevel");
|
|
19
19
|
if (!top) return [basename(cwd)];
|
|
20
20
|
|