@staix/agent-hub 0.9.0 → 0.11.0
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 +18 -0
- package/README.md +2 -2
- package/docs/operations.md +115 -9
- package/docs/quickstart.md +1 -1
- package/docs/security.md +5 -2
- package/docs/smoke.md +63 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +111 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2 -2
- package/src/adapters/acp.ts +10 -2
- package/src/adapters/local-worker.ts +4 -3
- package/src/cli/main.ts +6 -4
- package/src/hub/ask.ts +7 -2
- package/src/hub/board.ts +29 -1
- package/src/hub/config-trust.ts +2 -0
- package/src/hub/crash.ts +109 -0
- package/src/hub/daemon.ts +164 -15
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/routing.ts +21 -3
- package/src/hub/tasks.ts +120 -16
- package/src/local/deny.ts +2 -2
- package/src/local/proxy.ts +142 -0
- package/src/local/sandbox.ts +104 -15
- package/src/local/tools.ts +4 -2
- package/templates/config.json +4 -1
package/src/hub/routing.ts
CHANGED
|
@@ -135,6 +135,9 @@ export function assign(
|
|
|
135
135
|
now?: number;
|
|
136
136
|
/** Peers demoted for this task's class, with their recent failure weight (issue #36). */
|
|
137
137
|
demoted?: Record<PeerId, number>;
|
|
138
|
+
/** Review record per implementer, then per reviewer, for this class (issue #35); followed only when `adaptive`. */
|
|
139
|
+
reviews?: Record<PeerId, Record<PeerId, { score: number; n: number }>>;
|
|
140
|
+
adaptive?: { min: number };
|
|
138
141
|
} = {},
|
|
139
142
|
): Assignment {
|
|
140
143
|
const policy = routing.classes[task.class];
|
|
@@ -177,10 +180,25 @@ export function assign(
|
|
|
177
180
|
return out;
|
|
178
181
|
};
|
|
179
182
|
/** Demoted peers go behind the rest for this class (owners only); each group is then ordered by quota. */
|
|
180
|
-
|
|
183
|
+
/** Reviewers with enough recorded reviews of this owner's work swap places by how those reviews held up. */
|
|
184
|
+
const byRecord = (list: PeerId[], owner: PeerId | undefined): PeerId[] => {
|
|
185
|
+
const record = owner ? opts.reviews?.[owner] : undefined;
|
|
186
|
+
if (!record || !Object.keys(record).length) return list;
|
|
187
|
+
trace.push(` review record with ${owner} in ${task.class}: ${Object.entries(record).map(([r, s]) => `${r} ${s.n} reviews, ${Math.round(s.score * 100)}% held`).join("; ")}${opts.adaptive ? "" : " (review.adaptive is off)"}`);
|
|
188
|
+
if (!opts.adaptive) return list;
|
|
189
|
+
const known = (p: PeerId) => (record[p] && record[p].n >= opts.adaptive!.min ? record[p].score : undefined);
|
|
190
|
+
const slots = list.flatMap((p, i) => (known(p) === undefined ? [] : [i]));
|
|
191
|
+
const sorted = slots.map((i) => list[i]!).sort((a, b) => known(b)! - known(a)!);
|
|
192
|
+
const out = [...list];
|
|
193
|
+
slots.forEach((i, k) => (out[i] = sorted[k]!));
|
|
194
|
+
return out;
|
|
195
|
+
};
|
|
196
|
+
const rank = (ok: PeerId[], role: "owner" | "reviewer", owner?: PeerId): PeerId[] => {
|
|
181
197
|
const down = role === "owner" ? ok.filter((p) => opts.demoted?.[p]) : [];
|
|
182
198
|
if (down.length) trace.push(` demoted for ${task.class}: ${down.map((p) => `${p} (${opts.demoted![p]!.toFixed(1)} recent failures)`).join(", ")}`);
|
|
183
|
-
|
|
199
|
+
const ranked = [...byDrain(ok.filter((p) => !down.includes(p))), ...byDrain(down)];
|
|
200
|
+
// The review record comes after quota, so it decides among reviewers that have one: idle, then record, then quota.
|
|
201
|
+
return role === "reviewer" ? byRecord(ranked, owner) : ranked;
|
|
184
202
|
};
|
|
185
203
|
|
|
186
204
|
const pick = (list: PeerId[], role: "owner" | "reviewer", not?: PeerId): PeerId | undefined => {
|
|
@@ -190,7 +208,7 @@ export function assign(
|
|
|
190
208
|
trace.push(` ${role} candidate ${peer}: ${why ? `skipped, ${why}` : states[peer]}`);
|
|
191
209
|
if (!why) ok.push(peer);
|
|
192
210
|
}
|
|
193
|
-
const ranked = rank(ok, role);
|
|
211
|
+
const ranked = rank(ok, role, not);
|
|
194
212
|
// Demotion applies to local and Pi too: a demoted one loses its place ahead of the cloud peers.
|
|
195
213
|
const localTier = ranked.filter((p) => (p === LOCAL || p === PI) && !(role === "owner" && opts.demoted?.[p]));
|
|
196
214
|
if (localTier.length) return localTier.find((p) => states[p] === "idle") ?? localTier[0]; // local/Pi stays ahead of an idle cloud peer
|
package/src/hub/tasks.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface TasksDeps {
|
|
|
27
27
|
recordOverlap?: (task: number, owner: PeerId, others: { task: number; owner: PeerId; paths: string[]; symbols?: string[] }[]) => void;
|
|
28
28
|
/** Quota per peer from fresh readings (issue #36): routing drains the windows that reset soonest first. */
|
|
29
29
|
quota?: () => Record<PeerId, { headroom: number; resetsAt?: number }>;
|
|
30
|
+
/** Reviewer choice from recorded review outcomes (issue #35); off unless the project config turns it on. */
|
|
31
|
+
review?: { adaptive: boolean; min_reviews: number };
|
|
30
32
|
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
31
33
|
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
32
34
|
}
|
|
@@ -37,17 +39,24 @@ const ESCALATE_AFTER = 2;
|
|
|
37
39
|
// ponytail: fixed constants; make them config when someone needs to tune them.
|
|
38
40
|
const DEMOTION_HALF_LIFE_MS = 24 * 3_600_000;
|
|
39
41
|
const DEMOTE_AT = 1.5;
|
|
42
|
+
/** An approval counts as contradicted when work on the same places fails within this window (issue #35). */
|
|
43
|
+
const CONTRADICTION_WINDOW_MS = 7 * 86_400_000;
|
|
40
44
|
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
41
45
|
/** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
|
|
42
46
|
const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
|
|
43
47
|
|
|
48
|
+
/** A project path as one spelling (#67): no leading `./`, no repeated or trailing `/`; the root is `.`. */
|
|
49
|
+
export const normPath = (p: string) => p.replace(/^(\.\/)+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "") || ".";
|
|
50
|
+
|
|
44
51
|
/** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
|
|
45
52
|
export const samePlace = (a: string, b: string) => {
|
|
46
|
-
const
|
|
47
|
-
const [x, y] = [norm(a), norm(b)];
|
|
53
|
+
const [x, y] = [normPath(a), normPath(b)];
|
|
48
54
|
return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
|
|
49
55
|
};
|
|
50
56
|
|
|
57
|
+
/** Events that hand a task to an owner (or take it away); newer history records that owner on them (#67). */
|
|
58
|
+
const OWNERSHIP_EVENTS = new Set(["assigned", "escalated", "reassigned", "unassigned"]);
|
|
59
|
+
|
|
51
60
|
/** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
|
|
52
61
|
const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
|
|
53
62
|
/** A list of short strings; a lone string counts as a list of one. */
|
|
@@ -57,7 +66,7 @@ const fields = (input: unknown) => (input && typeof input === "object" && !Array
|
|
|
57
66
|
/** Tool callers are models: inputSchema is not enforced on the way in, so refs are normalized before they reach the board. */
|
|
58
67
|
function cleanRefs(input: unknown): TaskRefs {
|
|
59
68
|
const r = fields(input);
|
|
60
|
-
const paths = textList(r.paths);
|
|
69
|
+
const paths = [...new Set(textList(r.paths).map(normPath))];
|
|
61
70
|
const out: TaskRefs = {};
|
|
62
71
|
for (const k of ["repo", "branch", "commit"] as const) if (text(r[k])) out[k] = text(r[k])!;
|
|
63
72
|
if (paths.length) out.paths = paths;
|
|
@@ -67,7 +76,7 @@ function cleanRefs(input: unknown): TaskRefs {
|
|
|
67
76
|
/** The same for a plan (issue #31): only its four lists, each of short strings. */
|
|
68
77
|
function cleanPlan(input: unknown): TaskPlan {
|
|
69
78
|
const r = fields(input);
|
|
70
|
-
return Object.fromEntries(PLAN_KEYS.map((k) => [k, textList(r[k])] as const).filter(([, v]) => v.length));
|
|
79
|
+
return Object.fromEntries(PLAN_KEYS.map((k) => [k, k === "paths" ? [...new Set(textList(r[k]).map(normPath))] : textList(r[k])] as const).filter(([, v]) => v.length));
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
/** A plan as one line for other owners; the whole plan is on the board. */
|
|
@@ -122,11 +131,66 @@ export class Tasks {
|
|
|
122
131
|
return Object.fromEntries([...sums].filter(([, s]) => s.bad >= DEMOTE_AT && s.bad > s.good).map(([p, s]) => [p, s.bad]));
|
|
123
132
|
}
|
|
124
133
|
|
|
125
|
-
/**
|
|
134
|
+
/** How each reviewer's reviews of each implementer's work in a class held up (issue #35). */
|
|
135
|
+
reviewRecord(cls: TaskClass): Record<PeerId, Record<PeerId, { score: number; n: number }>> {
|
|
136
|
+
// Counted per task: a review that asked for changes and then approved is one review, not two.
|
|
137
|
+
const c = new Map<string, { reviewed: Set<number>; contradicted: Set<number> }>();
|
|
138
|
+
for (const r of this.d.board.reviews({ class: cls })) {
|
|
139
|
+
const key = `${r.implementer}\0${r.reviewer}`;
|
|
140
|
+
const s = c.get(key) ?? { reviewed: new Set<number>(), contradicted: new Set<number>() };
|
|
141
|
+
(r.kind === "contradicted" ? s.contradicted : s.reviewed).add(r.task);
|
|
142
|
+
c.set(key, s);
|
|
143
|
+
}
|
|
144
|
+
const out: Record<PeerId, Record<PeerId, { score: number; n: number }>> = {};
|
|
145
|
+
for (const [key, s] of c) {
|
|
146
|
+
const [implementer, reviewer] = key.split("\0") as [PeerId, PeerId];
|
|
147
|
+
const n = s.reviewed.size;
|
|
148
|
+
if (n) (out[implementer] ??= {})[reviewer] = { score: (n - [...s.contradicted].filter((t) => s.reviewed.has(t)).length) / n, n };
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
|
|
154
|
+
private requestedChanges(task: Task): Set<PeerId> {
|
|
155
|
+
// The window starts where the owner last changed. Newer entries carry the owner, so handing a task to the owner it
|
|
156
|
+
// already had (`ahub task assign <id> <owner>`) keeps the window; older ones do not, and any of them starts it.
|
|
157
|
+
let since = -1;
|
|
158
|
+
let owner: PeerId | null | undefined = null;
|
|
159
|
+
task.history.forEach((h, i) => {
|
|
160
|
+
if (!OWNERSHIP_EVENTS.has(h.event)) return;
|
|
161
|
+
if (h.owner === undefined || h.owner !== owner) since = i;
|
|
162
|
+
owner = h.owner;
|
|
163
|
+
});
|
|
164
|
+
return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What assignment weighs besides states and policy: quota, demotion and the review record. */
|
|
126
168
|
private weights(cls: TaskClass) {
|
|
127
169
|
const now = Date.now();
|
|
128
170
|
const quota = this.d.quota?.();
|
|
129
|
-
return { now, demoted: this.demoted(cls, now), ...(quota ? { quota } : {}) };
|
|
171
|
+
return { now, demoted: this.demoted(cls, now), reviews: this.reviewRecord(cls), ...(quota ? { quota } : {}), ...(this.d.review?.adaptive ? { adaptive: { min: this.d.review.min_reviews } } : {}) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Work on these places failed (a check failure, or changes requested): approvals of other tasks on the same places
|
|
176
|
+
* within the window were contradicted. Each approval counts once.
|
|
177
|
+
*/
|
|
178
|
+
private contradict(failed: Task): void {
|
|
179
|
+
if (this.isPii(failed)) return; // its places are left out everywhere else too
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const mine = this.places(failed);
|
|
182
|
+
for (const t of this.d.board.list("approved")) {
|
|
183
|
+
if (t.id === failed.id || !t.owner || this.isPii(t)) continue;
|
|
184
|
+
const approval = [...t.history].reverse().find((h) => h.event === "approved");
|
|
185
|
+
if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
|
|
186
|
+
const theirs = this.places(t);
|
|
187
|
+
// Blame needs the same file or symbol: a directory or `.` would contradict every approval under it. Older rows
|
|
188
|
+
// were stored as written, so both sides are compared in one spelling.
|
|
189
|
+
const file = (p: string) => normPath(p) !== "." && theirs.paths.some((q) => normPath(q) === normPath(p));
|
|
190
|
+
const same = mine.paths.some(file) || mine.symbols.some((x) => theirs.symbols.includes(x));
|
|
191
|
+
if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
|
|
192
|
+
this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
|
|
193
|
+
}
|
|
130
194
|
}
|
|
131
195
|
|
|
132
196
|
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; plan?: TaskPlan; owner?: PeerId; after?: unknown; urgent?: unknown }): Promise<Task> {
|
|
@@ -202,6 +266,18 @@ export class Tasks {
|
|
|
202
266
|
/** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
|
|
203
267
|
nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
|
|
204
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Model-written free text about an ordinary task (a done summary, a review note with its unmet items, a handoff)
|
|
271
|
+
* that matches a PII pattern stays on this machine (#69): every peer gets a stub, `local` too, which handles an
|
|
272
|
+
* ordinary task's turn like any other (gateway off campus allowed, memory capture on). The board keeps the text for
|
|
273
|
+
* `ahub task show`. A PII task's messages are private already.
|
|
274
|
+
*/
|
|
275
|
+
private screen(task: Task, text: string, what: string, to: PeerId | null | undefined): string {
|
|
276
|
+
if (!text || this.isPii(task) || to === USER || this.nameable(text)) return text;
|
|
277
|
+
this.d.notify(`task #${task.id}: the ${what} was withheld from ${to ?? "a peer"} (it matches a PII pattern)`);
|
|
278
|
+
return `[${what} withheld: it matches a PII pattern; ahub task show ${task.id}]`;
|
|
279
|
+
}
|
|
280
|
+
|
|
205
281
|
/** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
|
|
206
282
|
private where(hit: { paths: string[]; symbols: string[] }): string {
|
|
207
283
|
const shown = [...hit.paths.filter(this.nameable), ...hit.symbols.filter(this.nameable).map((s) => `symbol ${s}`)];
|
|
@@ -319,7 +395,7 @@ export class Tasks {
|
|
|
319
395
|
private async sendTask(task: Task, a: Assignment, context?: string, overlap = ""): Promise<void> {
|
|
320
396
|
const pii = this.isPii(task);
|
|
321
397
|
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
322
|
-
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
|
|
398
|
+
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${this.screen(task, h.note ?? "", "review note", a.owner)}`);
|
|
323
399
|
const facts = [`class ${task.class}`, a.owner === PI ? `backend pi/${a.piBackend ?? "dgx"}` : "", task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
|
|
324
400
|
const plan = planText(task.plan);
|
|
325
401
|
const body = [
|
|
@@ -331,7 +407,7 @@ export class Tasks {
|
|
|
331
407
|
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
332
408
|
brief ?? "",
|
|
333
409
|
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
334
|
-
context && !pii ? `Handoff from the previous owner:\n${context.slice(0, 3000)}` : "",
|
|
410
|
+
context && !pii ? `Handoff from the previous owner:\n${this.screen(task, context, "handoff", a.owner).slice(0, 3000)}` : "",
|
|
335
411
|
`Take it with hub_task_accept {id: ${task.id}, plan: {paths, symbols, signatures, insertion_points}} (what you will change, before you start${pii ? "" : "; owners of overlapping tasks see it"}) or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary: what changed, why, and the check you ran with its result, refs}.`,
|
|
336
412
|
].filter(Boolean).join("\n\n");
|
|
337
413
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
@@ -382,8 +458,11 @@ export class Tasks {
|
|
|
382
458
|
}
|
|
383
459
|
|
|
384
460
|
private need(id: unknown, open = false): Task {
|
|
385
|
-
|
|
386
|
-
|
|
461
|
+
// Callers are models (#70): an id is a whole number or a digit string, never an array that Number() would accept.
|
|
462
|
+
const n = typeof id === "number" ? id : typeof id === "string" && /^\s*\d+\s*$/.test(id) ? Number(id) : NaN;
|
|
463
|
+
if (!Number.isInteger(n)) throw new Error(`id must be a task number, not ${(typeof id === "number" ? String(id) : JSON.stringify(id ?? null)).slice(0, 60)}`);
|
|
464
|
+
const task = this.d.board.get(n);
|
|
465
|
+
if (!task) throw new Error(`no task #${n}`);
|
|
387
466
|
// Handing a task to someone else only makes sense while there is work left on it.
|
|
388
467
|
if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
389
468
|
return task;
|
|
@@ -482,6 +561,7 @@ export class Tasks {
|
|
|
482
561
|
}
|
|
483
562
|
this.d.board.update(id, HUB, "check failed", {}, `${outcome}\n${result.tail}`.trim());
|
|
484
563
|
if (task.owner) this.d.board.recordOutcome(task.owner, task.class, false);
|
|
564
|
+
this.contradict(task);
|
|
485
565
|
this.d.notify(`task ${this.publicTitle(task)}: its check failed (${outcome}); it stays with ${task.owner ?? by}`);
|
|
486
566
|
this.tell(task, `Task #${id}: its check failed.\n$ ${outcome}${result.tail ? `\n${result.tail}` : ""}\nFix it and call hub_task_done again.`, pii);
|
|
487
567
|
}
|
|
@@ -531,36 +611,56 @@ export class Tasks {
|
|
|
531
611
|
const r = task.refs;
|
|
532
612
|
const last = [...task.history].reverse().find((h) => h.event === "done");
|
|
533
613
|
const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
534
|
-
|
|
614
|
+
// A checklist that maps the change to its contract (issue #35): reviewers who check against the written plan catch more.
|
|
615
|
+
const plan = planText(task.plan);
|
|
616
|
+
const check = [...task.history].reverse().find((h) => h.event === "check passed");
|
|
617
|
+
const checklist = [
|
|
618
|
+
"Checklist:",
|
|
619
|
+
`- Map the changed signatures and call sites to ${plan ? `the plan (${plan})` : task.detail ? "the task detail above" : "the task title"}, and name each one that does not match.`,
|
|
620
|
+
`- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
|
|
621
|
+
`- List what is unmet in hub_review's unmet, one item each.`,
|
|
622
|
+
].join("\n");
|
|
623
|
+
const summary = last?.note ? this.screen(task, last.note, "summary", reviewer) : "(no summary)";
|
|
624
|
+
const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${summary}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}${!plan && task.detail ? `Task detail:\n${task.detail}\n` : ""}${checklist}\nGive your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note, unmet}.`;
|
|
535
625
|
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
536
626
|
}
|
|
537
627
|
|
|
538
|
-
async review(by: PeerId, id: unknown, verdict: unknown, note?: string): Promise<Task> {
|
|
628
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string, unmet?: unknown): Promise<Task> {
|
|
539
629
|
const task = this.need(id);
|
|
540
630
|
this.mine(task, by, "reviewer");
|
|
541
631
|
if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
|
|
542
632
|
if (task.state !== "in_review") throw new Error(`task #${task.id} is ${task.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
543
633
|
const pii = this.isPii(task);
|
|
634
|
+
const items = textList(unmet);
|
|
635
|
+
if (items.length) note = `${note ?? ""}\nUnmet: ${items.join("; ")}`.trim();
|
|
544
636
|
if (verdict === "approved") {
|
|
545
637
|
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
|
|
546
|
-
if (next.owner)
|
|
638
|
+
if (next.owner) {
|
|
639
|
+
this.d.board.recordOutcome(next.owner, next.class, true);
|
|
640
|
+
this.d.board.recordReview({ implementer: next.owner, reviewer: by, class: next.class, kind: "approved", task: next.id });
|
|
641
|
+
// Changes requested on this owner's work and the redo passed: those reviews caught something.
|
|
642
|
+
for (const r of this.requestedChanges(task)) {
|
|
643
|
+
this.d.board.recordReview({ implementer: next.owner, reviewer: r, class: next.class, kind: "caught", task: next.id });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
547
646
|
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
|
|
548
|
-
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
|
|
647
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${this.screen(next, note, "review note", next.owner)}` : ""}`, pii);
|
|
549
648
|
await this.releaseDependents(next);
|
|
550
649
|
return next;
|
|
551
650
|
}
|
|
552
651
|
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
|
|
553
652
|
if (rejected.owner) this.d.board.recordOutcome(rejected.owner, rejected.class, false);
|
|
653
|
+
this.contradict(rejected);
|
|
554
654
|
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
|
|
555
655
|
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
556
656
|
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
|
|
557
657
|
if (moved.owner !== rejected.owner) return moved;
|
|
558
658
|
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
559
|
-
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${note}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
|
|
659
|
+
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${this.screen(moved, note, "review note", moved.owner)}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
|
|
560
660
|
return moved;
|
|
561
661
|
}
|
|
562
662
|
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
563
|
-
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${note}` : ""} Fix it and call hub_task_done again.`, pii);
|
|
663
|
+
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${this.screen(reopened, note, "review note", reopened.owner)}` : ""} Fix it and call hub_task_done again.`, pii);
|
|
564
664
|
return reopened;
|
|
565
665
|
}
|
|
566
666
|
|
|
@@ -573,6 +673,8 @@ export class Tasks {
|
|
|
573
673
|
// The hub's own escalations are not counted: after repeated changes_requested each one already was, and after a
|
|
574
674
|
// Pi inference failure the backend failed, not the work. An escalation by hand counts on its own.
|
|
575
675
|
if (from && by !== HUB) this.d.board.recordOutcome(from, task.class, false);
|
|
676
|
+
// Only a reviewer that asked for changes on this work saw it fail: not an escalation of unreviewed work (a Pi failure).
|
|
677
|
+
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 });
|
|
576
678
|
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}`, context: why });
|
|
577
679
|
if (next.owner && next.owner !== from) {
|
|
578
680
|
this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
@@ -648,6 +750,8 @@ export class Tasks {
|
|
|
648
750
|
/** Auto notes: only transitions that carry content, never for PII. */
|
|
649
751
|
private note(task: Task, by: PeerId, kind: string, text: string): void {
|
|
650
752
|
if (this.isPii(task) || !this.d.memory) return;
|
|
753
|
+
// claude-mem's observer is a cloud model: model-written text that matches a PII pattern is not sent (#69).
|
|
754
|
+
if (!this.nameable(text)) return this.d.notify(`task #${task.id}: a ${kind} note was not saved to shared memory (it matches a PII pattern)`);
|
|
651
755
|
void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
652
756
|
}
|
|
653
757
|
|
package/src/local/deny.ts
CHANGED
|
@@ -30,14 +30,14 @@ export const sbplString = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"
|
|
|
30
30
|
* Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
|
|
31
31
|
* anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
|
|
32
32
|
*/
|
|
33
|
-
export function denyRegexes(root: string, extra: string[] = []): string[] {
|
|
33
|
+
export function denyRegexes(root: string, extra: string[] = [], withNames = true): string[] {
|
|
34
34
|
const names = DENY_NAMES.map((re) => {
|
|
35
35
|
const body = re.startsWith("^") ? re.slice(1) : `[^/]*${re}`;
|
|
36
36
|
return `/${body}${body.endsWith("$") ? "" : "[^/]*$"}`;
|
|
37
37
|
});
|
|
38
38
|
return [
|
|
39
39
|
...DENY_SEGMENTS.map((s) => `/${sbplEscape(s)}(/|$)`),
|
|
40
|
-
...names,
|
|
40
|
+
...(withNames ? names : []),
|
|
41
41
|
...extra.filter(Boolean).map((e) => `^${sbplEscape(root)}/.*${sbplEscape(e)}`),
|
|
42
42
|
].map((re) => `(regex ${sbplString(re)})`);
|
|
43
43
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { BlockList, isIP, connect, createServer, type Server, type Socket } from "node:net";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The only way out for the local worker's commands when `local.bash_network` is on (issue #65): an HTTP CONNECT proxy
|
|
6
|
+
* on loopback that opens tunnels to allowlisted hosts only. CONNECT only, so TLS stays end to end; plain HTTP is
|
|
7
|
+
* refused. A name that resolves to a loopback, private or link-local address is refused unless it is listed as an
|
|
8
|
+
* address: claude-mem and the Codex app-server listen on loopback without auth.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** What a project gets unless `local.network_allow` says otherwise: package registries and code hosts. */
|
|
12
|
+
export const DEFAULT_NETWORK_ALLOW = [
|
|
13
|
+
"registry.npmjs.org",
|
|
14
|
+
"registry.yarnpkg.com",
|
|
15
|
+
"pypi.org",
|
|
16
|
+
"files.pythonhosted.org",
|
|
17
|
+
"github.com",
|
|
18
|
+
"codeload.github.com",
|
|
19
|
+
"objects.githubusercontent.com",
|
|
20
|
+
"raw.githubusercontent.com",
|
|
21
|
+
"crates.io",
|
|
22
|
+
"static.crates.io",
|
|
23
|
+
"index.crates.io",
|
|
24
|
+
"proxy.golang.org",
|
|
25
|
+
"sum.golang.org",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export interface EgressProxy {
|
|
29
|
+
port: number;
|
|
30
|
+
url: string;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `host` or `host:port`; a name also matches its subdomains, an address only itself. Port 443 unless named. */
|
|
35
|
+
export function allowed(list: string[], host: string, port: number): boolean {
|
|
36
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
37
|
+
return list.some((raw) => {
|
|
38
|
+
const entry = raw.trim().toLowerCase();
|
|
39
|
+
const at = entry.lastIndexOf(":");
|
|
40
|
+
const [name, p] = at > 0 && !entry.includes("]") && /^\d+$/.test(entry.slice(at + 1)) ? [entry.slice(0, at), Number(entry.slice(at + 1))] : [entry, 443];
|
|
41
|
+
if (!name || p !== port) return false;
|
|
42
|
+
return h === name || (!isIP(name) && h.endsWith(`.${name}`));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Parsed, not prefix-matched: an IPv4-mapped IPv6 address in any spelling (`::ffff:7f00:1`) checks as its IPv4 address.
|
|
47
|
+
const INTERNAL = new BlockList();
|
|
48
|
+
// IPv4 nets as leading octets: the package check refuses private address literals in published files.
|
|
49
|
+
for (const [a, b, bits] of [[0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 168, 16], [198, 18, 15], [224, 0, 3]] as const) INTERNAL.addSubnet(`${a}.${b}.0.0`, bits, "ipv4");
|
|
50
|
+
for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
|
|
51
|
+
|
|
52
|
+
/** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
|
|
53
|
+
export function isInternal(address: string): boolean {
|
|
54
|
+
const family = isIP(address);
|
|
55
|
+
return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hostOf = (target: string) => {
|
|
59
|
+
try {
|
|
60
|
+
return ` ${new URL(target).host}`;
|
|
61
|
+
} catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void }): Promise<EgressProxy> {
|
|
67
|
+
const sockets = new Set<Socket>();
|
|
68
|
+
// Half-open on both sides: a client that sends its request and shuts its write side still gets the answer.
|
|
69
|
+
const server: Server = createServer({ allowHalfOpen: true }, (client) => {
|
|
70
|
+
sockets.add(client);
|
|
71
|
+
client.on("close", () => sockets.delete(client));
|
|
72
|
+
client.on("error", () => client.destroy());
|
|
73
|
+
let head = Buffer.alloc(0);
|
|
74
|
+
const onData = (chunk: Buffer) => {
|
|
75
|
+
head = Buffer.concat([head, chunk]);
|
|
76
|
+
const end = head.indexOf("\r\n\r\n");
|
|
77
|
+
if (end < 0) {
|
|
78
|
+
if (head.length > 8192) refuse(client, 431, "request header too large");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
client.off("data", onData);
|
|
82
|
+
client.pause();
|
|
83
|
+
const rest = head.subarray(end + 4);
|
|
84
|
+
const [method = "", target = "", version = ""] = head.subarray(0, end).toString("latin1").split("\r\n")[0]!.split(" ");
|
|
85
|
+
// Answer in the version asked (macOS nc wants HTTP/1.0 back); curl and git take either.
|
|
86
|
+
void tunnel(client, method, target, rest, version === "HTTP/1.0" ? "HTTP/1.0" : "HTTP/1.1");
|
|
87
|
+
};
|
|
88
|
+
client.on("data", onData);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const refuse = (client: Socket, code: number, why: string, target = "") => {
|
|
92
|
+
if (target) opts.log(`network: refused ${target} (${why})`);
|
|
93
|
+
client.end(`HTTP/1.1 ${code} ${code === 403 ? "Forbidden" : "Bad Request"}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nagent-hub: ${why}\n`);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const tunnel = async (client: Socket, method: string, target: string, rest: Buffer, http: string) => {
|
|
97
|
+
// The log gets a method and a host, never a path, a query or userinfo: those carry tokens.
|
|
98
|
+
if (method.toUpperCase() !== "CONNECT") return refuse(client, 403, "only CONNECT (https) goes through this proxy", `${/^[A-Za-z]{1,16}$/.test(method) ? method : "a request"}${hostOf(target)}`);
|
|
99
|
+
const m = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(target);
|
|
100
|
+
const host = m?.[1]!.replace(/^\[|\]$/g, "") ?? "";
|
|
101
|
+
if (!m || !(isIP(host) || /^[a-z0-9.-]+$/i.test(host))) return refuse(client, 400, "bad CONNECT target", "a CONNECT request");
|
|
102
|
+
const port = Number(m[2]);
|
|
103
|
+
if (!allowed(opts.allow, host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
|
|
104
|
+
let address = host;
|
|
105
|
+
if (!isIP(host)) {
|
|
106
|
+
try {
|
|
107
|
+
address = (await lookup(host)).address;
|
|
108
|
+
} catch {
|
|
109
|
+
return refuse(client, 403, `${host} does not resolve`, `${host}:${port}`);
|
|
110
|
+
}
|
|
111
|
+
// A listed name must not lead back into this machine or its network (DNS can say anything).
|
|
112
|
+
if (isInternal(address)) return refuse(client, 403, `${host} resolves to an internal address`, `${host}:${port}`);
|
|
113
|
+
}
|
|
114
|
+
const upstream = connect({ host: address, port, allowHalfOpen: true });
|
|
115
|
+
sockets.add(upstream);
|
|
116
|
+
upstream.on("close", () => sockets.delete(upstream));
|
|
117
|
+
upstream.once("connect", () => {
|
|
118
|
+
client.write(`${http} 200 Connection Established\r\n\r\n`);
|
|
119
|
+
if (rest.length) upstream.write(rest);
|
|
120
|
+
client.pipe(upstream);
|
|
121
|
+
upstream.pipe(client);
|
|
122
|
+
client.resume();
|
|
123
|
+
});
|
|
124
|
+
upstream.on("error", () => (client.writable && !client.destroyed ? refuse(client, 403, `${host}:${port} is unreachable`) : client.destroy()));
|
|
125
|
+
client.on("close", () => upstream.destroy());
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
server.once("error", reject);
|
|
130
|
+
server.listen(0, "127.0.0.1", () => {
|
|
131
|
+
const port = (server.address() as { port: number }).port;
|
|
132
|
+
resolve({
|
|
133
|
+
port,
|
|
134
|
+
url: `http://127.0.0.1:${port}`,
|
|
135
|
+
close: () => new Promise((done) => {
|
|
136
|
+
for (const s of sockets) s.destroy();
|
|
137
|
+
server.close(() => done());
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|