@staix/agent-hub 0.10.0 → 0.12.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/src/hub/daemon.ts CHANGED
@@ -14,7 +14,8 @@ import { PiPeer } from "../adapters/pi.ts";
14
14
  import { startModelRelay, type ModelRelay } from "../models/relay.ts";
15
15
  import type { MlxOptions } from "../models/mlx.ts";
16
16
  import { PiToolReceipts } from "../pi/tool-receipts.ts";
17
- import { profile } from "../local/sandbox.ts";
17
+ import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
18
+ import { DEFAULT_NETWORK_ALLOW, startEgressProxy, type EgressProxy } from "../local/proxy.ts";
18
19
  import { runTool, TOOL_SCHEMAS, type ToolContext } from "../local/tools.ts";
19
20
  import { LocalPeer } from "../adapters/local-worker.ts";
20
21
  import { Capture, skipTools } from "../memory/capture.ts";
@@ -62,8 +63,8 @@ export interface HubConfig {
62
63
  omniroute: OmniRouteConfig;
63
64
  pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
64
65
  mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
65
- /** `sandbox`: "deny-default" (issue #39), or "allow-default", the profile of 0.9 and earlier, kept for one release. */
66
- local: { deny: string[]; bash_network: boolean; max_steps: number; read_allow: string[]; sandbox: "deny-default" | "allow-default" };
66
+ /** `bash_network`: true is network through the egress proxy to `network_allow` (#65); "direct" is everything, until 0.13.0 (#83). */
67
+ local: { deny: string[]; bash_network: boolean | "direct"; network_allow: string[]; max_steps: number; read_allow: string[] };
67
68
  /** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
68
69
  approvals: { timeout_s: number; notify: boolean };
69
70
  /** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
@@ -82,6 +83,8 @@ export interface HubConfig {
82
83
  capabilities: Record<string, string[]>;
83
84
  /** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
84
85
  ignored?: string[];
86
+ /** Settings that were removed or are about to be (issue #83): what each does now, for `hub.log` and `ahub doctor`. */
87
+ retired?: string[];
85
88
  }
86
89
  export const DEFAULT_CONFIG: HubConfig = {
87
90
  watchdog_ms: DEFAULT_WATCHDOG_MS,
@@ -97,7 +100,7 @@ export const DEFAULT_CONFIG: HubConfig = {
97
100
  omniroute: DEFAULT_OMNIROUTE,
98
101
  pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
99
102
  mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
100
- local: { deny: [], bash_network: false, max_steps: 30, read_allow: [], sandbox: "deny-default" },
103
+ local: { deny: [], bash_network: false, network_allow: DEFAULT_NETWORK_ALLOW, max_steps: 30, read_allow: [] },
101
104
  // Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
102
105
  approvals: { timeout_s: 120, notify: false },
103
106
  tasks: { release_after_min: 30 },
@@ -137,6 +140,16 @@ export function loadConfig(cwd: string): HubConfig {
137
140
  if (!files.length) return DEFAULT_CONFIG;
138
141
  const file = files.reduce((a, b) => ({ ...a, ...b, ...Object.fromEntries(CONFIG_BLOCKS.filter((k) => a[k] !== undefined && b[k] !== undefined).map((k) => [k, { ...a[k], ...b[k] }])) }));
139
142
  delete file.ignored; // the hub's own record, never a file's
143
+ delete file.retired;
144
+ // Escape hatches with a stated end (issue #83). A removed one is ignored, never an error: an old config.local.json
145
+ // must not stop a hub from starting.
146
+ const retired: string[] = [];
147
+ if (file.local?.sandbox !== undefined) {
148
+ if (file.local.sandbox === "allow-default") retired.push('local.sandbox "allow-default" was removed in 0.12.0: the deny-default sandbox applies (local.read_allow adds paths)');
149
+ file.local = { ...file.local };
150
+ delete file.local.sandbox;
151
+ }
152
+ if (file.local?.bash_network === "direct") retired.push('local.bash_network "direct" (the open network) goes in 0.13.0: set it to true and list the hosts in local.network_allow');
140
153
  if (file.mlx != null && (typeof file.mlx !== "object" || Array.isArray(file.mlx))) throw new Error("mlx configuration must be an object");
141
154
  if (file.mlx?.provider !== undefined && !["ollama", "legacy"].includes(file.mlx.provider)) throw new Error("mlx.provider must be ollama or legacy");
142
155
  if (file.mlx?.provider === undefined && (file.mlx?.modelPath || file.mlx?.runtimeDir || file.mlx?.bin || file.mlx?.port)) {
@@ -168,6 +181,7 @@ export function loadConfig(cwd: string): HubConfig {
168
181
  capabilities: { ...file.capabilities },
169
182
  mlx,
170
183
  ...(ignored.length ? { ignored } : {}),
184
+ ...(retired.length ? { retired } : {}),
171
185
  };
172
186
  }
173
187
 
@@ -301,6 +315,7 @@ export async function startDaemon(opts: DaemonOptions) {
301
315
  // a record left now would make the next ordinary start look like one.
302
316
  if (recoveryOperation || restartFilePresent) try { removeSessions(opts.stateDir); } catch { /* nothing to remove */ }
303
317
  const autoResume = config.recovery.auto_resume_after_crash === true; // a string "false" is not a yes
318
+ const piAutoStart = config.pi.enabled && config.pi.auto_start;
304
319
  const startedAt = Date.now();
305
320
  /** What crash recovery did or asks the user to do, for `ahub status`. */
306
321
  const crashReport: string[] = [];
@@ -334,6 +349,16 @@ export async function startDaemon(opts: DaemonOptions) {
334
349
  return [k, fallback];
335
350
  })) as unknown as LimitsConfig;
336
351
  const limiter = new Limiter(limits);
352
+ // The local worker's and Pi's commands reach the network only through this proxy (issue #65); "direct" keeps the
353
+ // open network of 0.10 and earlier until 0.13.0 (issue #83), anything else means none.
354
+ let egress: EgressProxy | undefined;
355
+ if (config.local.bash_network === true) {
356
+ const allow = Array.isArray(config.local.network_allow) ? config.local.network_allow.filter((h): h is string => typeof h === "string") : DEFAULT_NETWORK_ALLOW;
357
+ egress = await startEgressProxy({ allow, log });
358
+ startupCleanup.push(() => void egress?.close());
359
+ log(`network: egress proxy on 127.0.0.1:${egress.port}, ${allow.length} allowed host(s) (local.network_allow)`);
360
+ }
361
+ const sandboxNetwork: SandboxNetwork = config.local.bash_network === "direct" ? true : egress ? { proxyPort: egress.port } : false;
337
362
  const admit = (env: Envelope, parent?: string): string | undefined => {
338
363
  // [FYI] is recorded and costs nobody a turn: nothing to limit.
339
364
  if (env.from === USER || env.from === HUB || env.from === DIGEST || env.priority === "fyi") return undefined;
@@ -422,6 +447,7 @@ export async function startDaemon(opts: DaemonOptions) {
422
447
  if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
423
448
  // Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
424
449
  for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
450
+ for (const line of config.retired ?? []) log(`config: ${line}`);
425
451
  const checksAllowed = Object.keys(checkCommands).length > 0;
426
452
  const checkTimeoutS = typeof config.checks.timeout_s === "number" && config.checks.timeout_s >= 1 ? Math.min(config.checks.timeout_s, 3600) : 600;
427
453
  const runningChecks = new Set<() => void>();
@@ -601,6 +627,13 @@ export async function startDaemon(opts: DaemonOptions) {
601
627
  taskOpsInFlight--;
602
628
  }
603
629
  };
630
+ /** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
631
+ const peerArg = (v: unknown, name: string): PeerId | undefined => {
632
+ if (v == null || v === "") return undefined;
633
+ const id = typeof v === "string" ? v.trim().toLowerCase() : undefined; // peer ids are lowercase; "Codex" means codex
634
+ if (!id || !PEER_ID.test(id)) throw new Error(`${name} must be a peer id, not ${JSON.stringify(v).slice(0, 60)}`);
635
+ return id;
636
+ };
604
637
  async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
605
638
  // Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
606
639
  // claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
@@ -621,9 +654,11 @@ export async function startDaemon(opts: DaemonOptions) {
621
654
  log(`capabilities: ${by} may not ${what} (${op})`);
622
655
  throw new Error(`${by} may not ${what} (no "${cap}" in capabilities.${by} in .agenthub/config.json)`);
623
656
  };
657
+ // Tool callers are models (#70): `owner` is a peer id or nothing, settled before anything reaches the board.
624
658
  if (op === "hub_task_propose") {
659
+ a = { ...a, owner: peerArg(a.owner, "owner") };
625
660
  need("propose", "propose tasks");
626
- if (typeof a.owner === "string" && a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
661
+ if (a.owner && a.owner !== by) need("assign", "hand tasks to other peers");
627
662
  }
628
663
  if (op === "hub_remember") need("remember", "save notes to shared memory");
629
664
  switch (op) {
@@ -661,8 +696,11 @@ export async function startDaemon(opts: DaemonOptions) {
661
696
  switch (op) {
662
697
  case "task_show":
663
698
  return JSON.stringify(board.get(Number(a.id)) ? { ...board.get(Number(a.id)), reviews: board.reviews({ task: Number(a.id) }) } : `no task #${a.id}`, null, 2);
664
- case "task_assign":
665
- return line(await tasks.assignTo(a.id, String(a.peer)));
699
+ case "task_assign": {
700
+ const peer = peerArg(a.peer, "peer");
701
+ if (!peer) throw new Error("peer is required");
702
+ return line(await tasks.assignTo(a.id, peer));
703
+ }
666
704
  case "task_escalate":
667
705
  return line(await tasks.escalate(USER, a.id));
668
706
  case "route_explain":
@@ -908,13 +946,26 @@ export async function startDaemon(opts: DaemonOptions) {
908
946
  const report = (line: string) => (crashReport.push(line), notify(`crash recovery: ${line}`));
909
947
  const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
910
948
  report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
949
+ const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
911
950
  for (const step of crashPlan(prev.peers)) {
951
+ // `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
952
+ // fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
953
+ if (step.peer === "pi" && piAutoStart) {
954
+ if (step.resume) {
955
+ const r = await start("pi", step.resume as Parameters<typeof startPeer>[1]);
956
+ if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
957
+ report(`pi not resumed (${String(r.error)}); ${step.how}`);
958
+ } else report(step.how);
959
+ const fresh = await start("pi", { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
960
+ const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
961
+ report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`);
962
+ continue;
963
+ }
912
964
  if (!step.resume || !autoResume) {
913
- const fresh = step.peer === "pi" && config.pi.enabled && config.pi.auto_start ? "; pi.auto_start starts a fresh session" : "";
914
- report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}${fresh}`);
965
+ report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`);
915
966
  continue;
916
967
  }
917
- const r = await startPeer(step.peer, step.resume as Parameters<typeof startPeer>[1]).catch((e: Error) => ({ ok: false, error: e.message }));
968
+ const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
918
969
  report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`);
919
970
  }
920
971
  writeStatus();
@@ -1038,7 +1089,7 @@ export async function startDaemon(opts: DaemonOptions) {
1038
1089
 
1039
1090
  // One start per peer at a time: a second `ahub codex` must not tear down an adapter that is still coming up.
1040
1091
  const starting = new Map<string, Promise<Record<string, unknown>>>();
1041
- function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown>> {
1092
+ function startPeer(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean }): Promise<Record<string, unknown>> {
1042
1093
  if (stopping) return Promise.resolve({ ok: false, error: "hub is stopping" });
1043
1094
  if (peer === "pi" && starting.has(peer)) return Promise.resolve({ ok: false, error: "Pi start is in progress; inspect status before retrying" });
1044
1095
  const running = starting.get(peer) ?? startPeerOnce(peer, args).finally(() => starting.delete(peer));
@@ -1062,7 +1113,7 @@ export async function startDaemon(opts: DaemonOptions) {
1062
1113
  };
1063
1114
  }
1064
1115
 
1065
- async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }): Promise<Record<string, unknown>> {
1116
+ async function startPeerOnce(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean }): Promise<Record<string, unknown>> {
1066
1117
  let unmute: (() => void) | undefined;
1067
1118
  try {
1068
1119
  return await startPeerBody(peer, args, (p) => { unmute = muteState(p); });
@@ -1071,7 +1122,7 @@ export async function startDaemon(opts: DaemonOptions) {
1071
1122
  }
1072
1123
  }
1073
1124
 
1074
- async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
1125
+ async function startPeerBody(peer: string, args: { model?: string; route?: string; mode?: "headless" | "tui"; backend?: "auto" | "dgx" | "mlx"; sessionId?: string; sessionFile?: string; fresh?: boolean }, mute: (p: PeerAdapter) => void): Promise<Record<string, unknown>> {
1075
1126
  if (peer === "pi") {
1076
1127
  if (args.mode !== undefined && !["headless", "tui"].includes(args.mode)) return { ok: false, error: "invalid Pi mode" };
1077
1128
  if (args.backend !== undefined && !["auto", "dgx", "mlx"].includes(args.backend)) return { ok: false, error: "invalid Pi backend" };
@@ -1087,7 +1138,8 @@ export async function startDaemon(opts: DaemonOptions) {
1087
1138
  const unclaimed = existing.state === "offline" && !saved.sessionId && !saved.sessionFile;
1088
1139
  if (changesOwner && (existing.state === "busy" || (existing.state !== "offline" && !existing.recoveryReady))) return { ok: false, error: "Pi is busy; wait for agent_settled before changing mode/backend" };
1089
1140
  if (unclaimed) {
1090
- if (!args.sessionId && !args.sessionFile) args = { ...args, ...existing.pendingResume };
1141
+ // `fresh` (crash recovery's fallback, #66): the pending session is the one that just failed to load.
1142
+ if (!args.sessionId && !args.sessionFile && !args.fresh) args = { ...args, ...existing.pendingResume };
1091
1143
  // Revoke the previous launch bridge before issuing another launch. A late
1092
1144
  // process from the abandoned CLI cannot claim the replacement owner.
1093
1145
  // The replacement's start event is the only state change the console should see (issue #42).
@@ -1177,7 +1229,8 @@ export async function startDaemon(opts: DaemonOptions) {
1177
1229
  let piReply: Envelope | undefined;
1178
1230
  const ctx: ToolContext = {
1179
1231
  cwd: opts.cwd, deny: config.local.deny,
1180
- sandboxProfile: profile(opts.cwd, config.local.bash_network, config.local.read_allow, config.local.deny, config.local.sandbox === "allow-default" ? "allow" : "deny"),
1232
+ sandboxProfile: profile(opts.cwd, sandboxNetwork, config.local.read_allow, config.local.deny),
1233
+ sandboxEnv: proxyEnv(sandboxNetwork),
1181
1234
  permit: (title) => onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }).then((picked) => picked === "allow" && pi.acceptingTools && bus.peers.get("pi") === pi),
1182
1235
  send: (text, to) => {
1183
1236
  if (to?.some((id) => !bus.peers.has(id) && id !== USER)) return "error: unknown peer";
@@ -1268,7 +1321,7 @@ export async function startDaemon(opts: DaemonOptions) {
1268
1321
  omni,
1269
1322
  ...(sidecar && route ? { sidecar, route } : {}),
1270
1323
  fixedModel: args.model ?? routing.local.fixed_model,
1271
- tools: { deny: config.local.deny, bashNetwork: config.local.bash_network, readAllow: config.local.read_allow, sandbox: config.local.sandbox, permit },
1324
+ tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, permit },
1272
1325
  ...(capture ? { capture } : {}),
1273
1326
  taskTool: (name, a, turn) => taskOp("local", name, a, true, turn.pii),
1274
1327
  turnPolicy: (envs) => tasks.turnPolicy(envs),
@@ -1733,6 +1786,7 @@ export async function startDaemon(opts: DaemonOptions) {
1733
1786
  // logged and shutdown finishes anyway, or the process ignores SIGTERM forever.
1734
1787
  for (const exit of exits) if (exit.status === "rejected") log(`peer stop failed during shutdown: ${(exit.reason as Error)?.message ?? exit.reason}`);
1735
1788
  await modelRelay?.close();
1789
+ await egress?.close();
1736
1790
  await piReceipts?.close();
1737
1791
  budget.close();
1738
1792
  board.close();
@@ -1774,11 +1828,15 @@ export async function startDaemon(opts: DaemonOptions) {
1774
1828
  if (crashed) {
1775
1829
  // This run owns the record now, so its clean stop removes it even if no peer attaches to rewrite it.
1776
1830
  try { if (readSessions(opts.stateDir)?.instanceId === crashed.instanceId) writeSessions(opts.stateDir, { ...crashed, instanceId, at: Date.now() }); } catch (error) { log(`session record not adopted: ${(error as Error).message}`); }
1777
- void recoverAfterCrash(crashed).catch((error) => log(`crash recovery failed: ${(error as Error).message}`));
1831
+ void recoverAfterCrash(crashed).catch((error) => {
1832
+ log(`crash recovery failed: ${(error as Error).message}`);
1833
+ // pi.auto_start still holds: a running Pi answers `already`, a starting one refuses the second start.
1834
+ if (piAutoStart) void startPeer("pi", {}).catch((e) => log(`Pi auto-start failed: ${e.message}`));
1835
+ });
1778
1836
  }
1779
- // Skipped only when crash recovery itself starts Pi again on its recorded session.
1780
- const piResumes = !!crashed && autoResume && crashPlan(crashed.peers).some((s) => s.peer === "pi" && s.resume);
1781
- if (config.pi.enabled && config.pi.auto_start && !recoveryActive() && !piResumes) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
1837
+ // After a crash that recorded Pi, crash recovery starts it (#66): its recorded session first, a fresh one if that fails.
1838
+ const piRecovered = !!crashed?.peers.some((p) => p.peer === "pi");
1839
+ if (piAutoStart && !recoveryActive() && !piRecovered) void startPeer("pi", {}).catch((error) => log(`Pi auto-start failed: ${error.message}`));
1782
1840
  return { bus, token, port: server.port as number, stop, stopped: new Promise<void>((r) => (onStop = r)) };
1783
1841
  } finally {
1784
1842
  if (!ready) for (const cleanup of startupCleanup.reverse()) { try { cleanup(); } catch { /* preserve startup error */ } }
package/src/hub/tasks.ts CHANGED
@@ -45,13 +45,18 @@ const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
45
45
  /** Board events that leave a task where its completion check found it; any other event means it moved on meanwhile. */
46
46
  const QUIET_EVENTS = new Set(["answer", "reviewer changed"]);
47
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
+
48
51
  /** Same path, or one is a directory of the other; the project root (`.`) holds everything. */
49
52
  export const samePlace = (a: string, b: string) => {
50
- const norm = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "") || ".";
51
- const [x, y] = [norm(a), norm(b)];
53
+ const [x, y] = [normPath(a), normPath(b)];
52
54
  return x === "." || y === "." || x === y || x.startsWith(`${y}/`) || y.startsWith(`${x}/`);
53
55
  };
54
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
+
55
60
  /** One line of model-written text: whitespace (newlines included) collapses, so it can never start a forged log line. */
56
61
  const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.replace(/\s+/g, " ").trim().slice(0, 300) : undefined);
57
62
  /** A list of short strings; a lone string counts as a list of one. */
@@ -61,7 +66,7 @@ const fields = (input: unknown) => (input && typeof input === "object" && !Array
61
66
  /** Tool callers are models: inputSchema is not enforced on the way in, so refs are normalized before they reach the board. */
62
67
  function cleanRefs(input: unknown): TaskRefs {
63
68
  const r = fields(input);
64
- const paths = textList(r.paths);
69
+ const paths = [...new Set(textList(r.paths).map(normPath))];
65
70
  const out: TaskRefs = {};
66
71
  for (const k of ["repo", "branch", "commit"] as const) if (text(r[k])) out[k] = text(r[k])!;
67
72
  if (paths.length) out.paths = paths;
@@ -71,7 +76,7 @@ function cleanRefs(input: unknown): TaskRefs {
71
76
  /** The same for a plan (issue #31): only its four lists, each of short strings. */
72
77
  function cleanPlan(input: unknown): TaskPlan {
73
78
  const r = fields(input);
74
- 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));
75
80
  }
76
81
 
77
82
  /** A plan as one line for other owners; the whole plan is on the board. */
@@ -147,7 +152,15 @@ export class Tasks {
147
152
 
148
153
  /** Who asked for changes on the current owner's work: requests made before the task changed hands were about someone else's. */
149
154
  private requestedChanges(task: Task): Set<PeerId> {
150
- const since = task.history.findLastIndex((h) => ["assigned", "escalated", "reassigned", "unassigned"].includes(h.event));
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
+ });
151
164
  return new Set(task.history.slice(since + 1).filter((h) => h.event === "changes_requested").map((h) => h.by));
152
165
  }
153
166
 
@@ -171,8 +184,10 @@ export class Tasks {
171
184
  const approval = [...t.history].reverse().find((h) => h.event === "approved");
172
185
  if (!approval || now - approval.at > CONTRADICTION_WINDOW_MS || approval.by === USER) continue;
173
186
  const theirs = this.places(t);
174
- // Blame needs the same file or symbol: a directory or `.` would contradict every approval under it.
175
- const same = mine.paths.some((p) => theirs.paths.includes(p)) || mine.symbols.some((x) => theirs.symbols.includes(x));
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));
176
191
  if (!same || this.d.board.reviews({ task: t.id }).some((r) => r.kind === "contradicted")) continue;
177
192
  this.d.board.recordReview({ implementer: t.owner, reviewer: approval.by, class: t.class, kind: "contradicted", task: t.id });
178
193
  }
@@ -251,6 +266,18 @@ export class Tasks {
251
266
  /** Whether a model-written name may be shown to other peers and in the log: one matching a PII pattern may be PII. */
252
267
  nameable = (t: string): boolean => !this.isPii({ signals: detectSignals({ title: "", detail: t, refs: {} }, this.d.routing(), this.d.cwd) });
253
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
+
254
281
  /** Where two tasks meet: shared paths, then shared symbols, leaving out any name that matches a PII pattern. */
255
282
  private where(hit: { paths: string[]; symbols: string[] }): string {
256
283
  const shown = [...hit.paths.filter(this.nameable), ...hit.symbols.filter(this.nameable).map((s) => `symbol ${s}`)];
@@ -368,7 +395,7 @@ export class Tasks {
368
395
  private async sendTask(task: Task, a: Assignment, context?: string, overlap = ""): Promise<void> {
369
396
  const pii = this.isPii(task);
370
397
  const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
371
- 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)}`);
372
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("; ");
373
400
  const plan = planText(task.plan);
374
401
  const body = [
@@ -380,7 +407,7 @@ export class Tasks {
380
407
  rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
381
408
  brief ?? "",
382
409
  // What the previous owner left behind. Peer-written free text: never attached to a PII task.
383
- 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)}` : "",
384
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}.`,
385
412
  ].filter(Boolean).join("\n\n");
386
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 } : {}) }));
@@ -431,8 +458,11 @@ export class Tasks {
431
458
  }
432
459
 
433
460
  private need(id: unknown, open = false): Task {
434
- const task = this.d.board.get(Number(id));
435
- if (!task) throw new Error(`no task #${id}`);
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}`);
436
466
  // Handing a task to someone else only makes sense while there is work left on it.
437
467
  if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
438
468
  return task;
@@ -590,7 +620,8 @@ export class Tasks {
590
620
  `- ${check ? `Check result: ${(check.note ?? "").split("\n")[0]}` : "No check ran for this class: run the affected tests yourself."}`,
591
621
  `- List what is unmet in hub_review's unmet, one item each.`,
592
622
  ].join("\n");
593
- const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no 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}.`;
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}.`;
594
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 } : {}) }));
595
626
  }
596
627
 
@@ -613,7 +644,7 @@ export class Tasks {
613
644
  }
614
645
  }
615
646
  this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
616
- 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);
617
648
  await this.releaseDependents(next);
618
649
  return next;
619
650
  }
@@ -625,11 +656,11 @@ export class Tasks {
625
656
  const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
626
657
  if (moved.owner !== rejected.owner) return moved;
627
658
  // Nobody to escalate to: the owner still has to hear the verdict and the note.
628
- 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);
629
660
  return moved;
630
661
  }
631
662
  const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
632
- 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);
633
664
  return reopened;
634
665
  }
635
666
 
@@ -719,6 +750,8 @@ export class Tasks {
719
750
  /** Auto notes: only transitions that carry content, never for PII. */
720
751
  private note(task: Task, by: PeerId, kind: string, text: string): void {
721
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)`);
722
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 } });
723
756
  }
724
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,169 @@
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
+ // RFC 8215's local-use NAT64 prefix puts the IPv4 address where the operator's prefix length says: refused outright.
51
+ for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["64:ff9b:1::", 48]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
52
+ // The well-known NAT64 prefix (issue #82): with DNS64, a name whose only record is an internal IPv4 address comes back
53
+ // as 64:ff9b::<that address>, and the NAT64 gateway carries the connection there. Its IPv4 address is what counts.
54
+ const NAT64 = new BlockList();
55
+ NAT64.addSubnet("64:ff9b::", 96, "ipv6");
56
+
57
+ /** The IPv4 address in the last 32 bits of an IPv6 address, whatever its spelling. */
58
+ function lastIPv4(v6: string): string {
59
+ const [head = "", tail] = new URL(`http://[${v6}]`).hostname.slice(1, -1).split("::");
60
+ const h = head ? head.split(":") : [];
61
+ const t = tail ? tail.split(":") : [];
62
+ const all = tail === undefined ? h : [...h, ...Array<string>(8 - h.length - t.length).fill("0"), ...t];
63
+ const [a = 0, b = 0] = all.slice(6).map((g) => parseInt(g, 16));
64
+ return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
65
+ }
66
+
67
+ /** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
68
+ export function isInternal(address: string): boolean {
69
+ // Only a non-global address carries a zone (`fe80::1%en0`, `::1%lo0`), and BlockList cannot parse one: internal.
70
+ if (address.includes("%")) return true;
71
+ const family = isIP(address);
72
+ if (family === 6 && NAT64.check(address, "ipv6")) return isInternal(lastIPv4(address));
73
+ return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
74
+ }
75
+
76
+ const hostOf = (target: string) => {
77
+ try {
78
+ return ` ${new URL(target).host}`;
79
+ } catch {
80
+ return "";
81
+ }
82
+ };
83
+
84
+ export function startEgressProxy(opts: { allow: string[]; log: (line: string) => void }): Promise<EgressProxy> {
85
+ const sockets = new Set<Socket>();
86
+ // Half-open on both sides: a client that sends its request and shuts its write side still gets the answer.
87
+ const server: Server = createServer({ allowHalfOpen: true }, (client) => {
88
+ sockets.add(client);
89
+ client.on("close", () => sockets.delete(client));
90
+ client.on("error", () => client.destroy());
91
+ let head = Buffer.alloc(0);
92
+ const onData = (chunk: Buffer) => {
93
+ head = Buffer.concat([head, chunk]);
94
+ const end = head.indexOf("\r\n\r\n");
95
+ if (end < 0) {
96
+ // Once: later chunks of the same request must not refuse (and log) again. Nothing of the header is logged.
97
+ if (head.length > 8192) client.off("data", onData), refuse(client, 431, "request header too large", "a request");
98
+ return;
99
+ }
100
+ client.off("data", onData);
101
+ client.pause();
102
+ const rest = head.subarray(end + 4);
103
+ const [method = "", target = "", version = ""] = head.subarray(0, end).toString("latin1").split("\r\n")[0]!.split(" ");
104
+ // Answer in the version asked (macOS nc wants HTTP/1.0 back); curl and git take either.
105
+ void tunnel(client, method, target, rest, version === "HTTP/1.0" ? "HTTP/1.0" : "HTTP/1.1");
106
+ };
107
+ client.on("data", onData);
108
+ });
109
+
110
+ const refuse = (client: Socket, code: number, why: string, target = "") => {
111
+ if (target) opts.log(`network: refused ${target} (${why})`);
112
+ 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`);
113
+ };
114
+
115
+ const tunnel = async (client: Socket, method: string, target: string, rest: Buffer, http: string) => {
116
+ // The log gets a method and a host, never a path, a query or userinfo: those carry tokens.
117
+ 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)}`);
118
+ const m = /^(\[[^\]]+\]|[^:]+):(\d{1,5})$/.exec(target);
119
+ const host = m?.[1]!.replace(/^\[|\]$/g, "") ?? "";
120
+ if (!m || !(isIP(host) || /^[a-z0-9.-]+$/i.test(host))) return refuse(client, 400, "bad CONNECT target", "a CONNECT request");
121
+ const port = Number(m[2]);
122
+ if (!allowed(opts.allow, host, port)) return refuse(client, 403, `${host}:${port} is not in local.network_allow`, `${host}:${port}`);
123
+ let address = host;
124
+ if (!isIP(host)) {
125
+ try {
126
+ address = (await lookup(host)).address;
127
+ } catch {
128
+ return refuse(client, 403, `${host} does not resolve`, `${host}:${port}`);
129
+ }
130
+ // A listed name must not lead back into this machine or its network (DNS can say anything).
131
+ if (isInternal(address)) return refuse(client, 403, `${host} resolves to an internal address`, `${host}:${port}`);
132
+ }
133
+ const upstream = connect({ host: address, port, allowHalfOpen: true });
134
+ sockets.add(upstream);
135
+ upstream.on("close", () => sockets.delete(upstream));
136
+ let open = false;
137
+ upstream.once("connect", () => {
138
+ open = true;
139
+ client.write(`${http} 200 Connection Established\r\n\r\n`);
140
+ if (rest.length) upstream.write(rest);
141
+ client.pipe(upstream);
142
+ upstream.pipe(client);
143
+ client.resume();
144
+ });
145
+ // Before the tunnel opens a failure is a refusal, logged with the error code only (never its text). After, the
146
+ // stream is the client's TLS: an HTTP answer would corrupt it, so the client is just closed.
147
+ upstream.on("error", (e: NodeJS.ErrnoException) => {
148
+ if (open || !client.writable || client.destroyed) return void client.destroy();
149
+ const code = /^E[A-Z]{2,20}$/.test(e.code ?? "") ? `, ${e.code}` : "";
150
+ refuse(client, 403, `${host}:${port} is unreachable${code}`, `${host}:${port}`);
151
+ });
152
+ client.on("close", () => upstream.destroy());
153
+ };
154
+
155
+ return new Promise((resolve, reject) => {
156
+ server.once("error", reject);
157
+ server.listen(0, "127.0.0.1", () => {
158
+ const port = (server.address() as { port: number }).port;
159
+ resolve({
160
+ port,
161
+ url: `http://127.0.0.1:${port}`,
162
+ close: () => new Promise((done) => {
163
+ for (const s of sockets) s.destroy();
164
+ server.close(() => done());
165
+ }),
166
+ });
167
+ });
168
+ });
169
+ }