@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/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/docs/operations.md +64 -19
- package/docs/quickstart.md +1 -1
- package/docs/security.md +3 -2
- package/docs/smoke.md +54 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +42 -5
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/local-worker.ts +4 -3
- package/src/cli/main.ts +1 -0
- package/src/hub/ask.ts +7 -2
- package/src/hub/board.ts +3 -1
- package/src/hub/config-trust.ts +1 -1
- package/src/hub/crash.ts +10 -5
- package/src/hub/daemon.ts +78 -20
- package/src/hub/tasks.ts +48 -15
- package/src/local/deny.ts +2 -2
- package/src/local/proxy.ts +169 -0
- package/src/local/sandbox.ts +66 -39
- package/src/local/tools.ts +4 -2
- package/templates/config.json +1 -1
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
|
-
/** `
|
|
66
|
-
local: { deny: string[]; bash_network: boolean
|
|
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: []
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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) =>
|
|
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
|
-
//
|
|
1780
|
-
const
|
|
1781
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
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
|
+
}
|