@staix/agent-hub 0.20.0 → 0.21.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 +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { processLiveness, processSignature } from "@staix/agent-mem/platform";
|
|
5
|
+
import { MAX_PROJECTION_INPUT } from "./text-projection.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #508 (AC1, AC2, AC5): the source registry. A source is a snapshot of bytes a person registered, named by its content, kept under the hub state dir.
|
|
9
|
+
* Pure over the filesystem: no daemon, no network (the URL fetch is a separate function the CLI calls), no model. Every refusal names its cap.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_SOURCE_BYTES = MAX_PROJECTION_INPUT;
|
|
12
|
+
export const MAX_REDIRECTS = 5;
|
|
13
|
+
export const MAX_SOURCES = 10_000;
|
|
14
|
+
export const MAX_ORIGINS = 256;
|
|
15
|
+
export const MAX_ORIGIN_CHARS = 2000;
|
|
16
|
+
/**
|
|
17
|
+
* One cap per file, used by the writer AND the reader, so what `add` accepts can always be read back (a record the reader cannot read is treated as damaged,
|
|
18
|
+
* and damage must never be mistaken for absence). `meta.json` holds at most MAX_ORIGINS origins of MAX_ORIGIN_CHARS characters, each up to 3 UTF-8 bytes
|
|
19
|
+
* per UTF-16 unit, twice over (the first origin is also the record's own), plus indentation: under 1.7 MiB, so 2 MiB. Text is the projection of the bytes:
|
|
20
|
+
* a converter's is at most 256 KiB, a plain file's is its own decoded bytes, at most the raw cap.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_META_BYTES = 2 * 1024 * 1024;
|
|
23
|
+
export const MAX_SOURCE_TEXT = MAX_SOURCE_BYTES;
|
|
24
|
+
/** How long a registration waits for another process's registration before it gives up. A lock is never taken from its holder. */
|
|
25
|
+
export const LOCK_WAIT_MS = 10_000;
|
|
26
|
+
const ID = /^src:[0-9a-f]{16}$/;
|
|
27
|
+
export type SourceKind = "file" | "url";
|
|
28
|
+
export interface SourceOrigin { kind: SourceKind; origin: string; added_at: number; private: boolean }
|
|
29
|
+
/** `meta.json`: the spec's `{id, kind, origin, sha256, bytes, added_at, private}` for the first registration, plus every origin ever registered for these bytes. */
|
|
30
|
+
export interface SourceMeta { id: string; kind: SourceKind; origin: string; sha256: string; bytes: number; added_at: number; private: boolean; origins: SourceOrigin[] }
|
|
31
|
+
export interface SourceAdd { id: string; created: boolean; private: boolean; upgraded: boolean; text: boolean }
|
|
32
|
+
|
|
33
|
+
export const sourceId = (sha256: string): string => {
|
|
34
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error("source id needs a SHA-256 hex digest");
|
|
35
|
+
return `src:${sha256.slice(0, 16)}`;
|
|
36
|
+
};
|
|
37
|
+
export const sha256Of = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
|
|
38
|
+
export const validSourceId = (value: unknown): value is string => typeof value === "string" && ID.test(value);
|
|
39
|
+
/** `src:` is not a safe path segment on every filesystem, so the directory drops the colon. */
|
|
40
|
+
const dirName = (id: string): string => id.replace(":", "-");
|
|
41
|
+
|
|
42
|
+
function atomic(path: string, data: string | Buffer, mode = 0o600): void {
|
|
43
|
+
const temporary = `${path}.${crypto.randomUUID()}.tmp`;
|
|
44
|
+
try { writeFileSync(temporary, data, { mode, flag: "wx" }); chmodSync(temporary, mode); renameSync(temporary, path); }
|
|
45
|
+
catch (error) { rmSync(temporary, { force: true }); throw error; }
|
|
46
|
+
}
|
|
47
|
+
/** A regular, single-link file read through one descriptor with a hard size cap; anything else is no record. */
|
|
48
|
+
function boundedRead(path: string, cap: number): Buffer {
|
|
49
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
50
|
+
try {
|
|
51
|
+
const st = fstatSync(fd);
|
|
52
|
+
if (!st.isFile() || st.nlink !== 1 || st.size > cap) throw new Error("not a bounded regular file");
|
|
53
|
+
const bytes = Buffer.alloc(st.size); let got = 0;
|
|
54
|
+
while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
|
|
55
|
+
if (got !== st.size) throw new Error("file changed while read");
|
|
56
|
+
return bytes;
|
|
57
|
+
} finally { closeSync(fd); }
|
|
58
|
+
}
|
|
59
|
+
const cleanOrigin = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= MAX_ORIGIN_CHARS && !/[\x00-\x1f\x7f]/.test(value);
|
|
60
|
+
function parseMeta(text: string, id: string): SourceMeta | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
63
|
+
const origins = raw.origins;
|
|
64
|
+
if (raw.id !== id || !["file", "url"].includes(raw.kind as string) || !cleanOrigin(raw.origin) || typeof raw.sha256 !== "string" || sourceId(raw.sha256) !== id
|
|
65
|
+
|| !Number.isSafeInteger(raw.bytes) || (raw.bytes as number) < 0 || (raw.bytes as number) > MAX_SOURCE_BYTES || !Number.isSafeInteger(raw.added_at) || typeof raw.private !== "boolean"
|
|
66
|
+
|| !Array.isArray(origins) || !origins.length || origins.length > MAX_ORIGINS) return undefined;
|
|
67
|
+
const kept: SourceOrigin[] = [];
|
|
68
|
+
for (const item of origins as Record<string, unknown>[]) {
|
|
69
|
+
if (!item || !["file", "url"].includes(item.kind as string) || !cleanOrigin(item.origin) || !Number.isSafeInteger(item.added_at) || typeof item.private !== "boolean") return undefined;
|
|
70
|
+
kept.push({ kind: item.kind as SourceKind, origin: item.origin as string, added_at: item.added_at as number, private: item.private });
|
|
71
|
+
}
|
|
72
|
+
// The flag is the OR of every registration; a record whose flag is lower than one of its origins is damaged, not trusted.
|
|
73
|
+
if (kept.some(item => item.private) && raw.private !== true) return undefined;
|
|
74
|
+
return { id, kind: raw.kind as SourceKind, origin: raw.origin as string, sha256: raw.sha256, bytes: raw.bytes as number, added_at: raw.added_at as number, private: raw.private, origins: kept };
|
|
75
|
+
} catch { return undefined; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `sources/<id>/` under the hub state dir: `meta.json`, `raw` (the exact bytes) and `text` (the projection, only when one exists).
|
|
80
|
+
* The raw cap is the text-projection input cap, and nothing is truncated: an input over it is refused with the cap in the reason.
|
|
81
|
+
*/
|
|
82
|
+
export class SourceRegistry {
|
|
83
|
+
readonly root: string;
|
|
84
|
+
private readonly lockWaitMs: number;
|
|
85
|
+
constructor(stateDir: string, options: { lockWaitMs?: number } = {}) { this.root = join(stateDir, "sources"); this.lockWaitMs = options.lockWaitMs ?? LOCK_WAIT_MS; }
|
|
86
|
+
|
|
87
|
+
private dir(id: string): string { return join(this.root, dirName(id)); }
|
|
88
|
+
|
|
89
|
+
get(id: string): SourceMeta | undefined {
|
|
90
|
+
if (!validSourceId(id)) return undefined;
|
|
91
|
+
try { return parseMeta(boundedRead(join(this.dir(id), "meta.json"), MAX_META_BYTES).toString("utf8"), id); } catch { return undefined; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
list(): SourceMeta[] {
|
|
95
|
+
let names: string[];
|
|
96
|
+
try { names = readdirSync(this.root); } catch { return []; }
|
|
97
|
+
const rows: SourceMeta[] = [];
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const match = /^src-([0-9a-f]{16})$/.exec(name);
|
|
100
|
+
const meta = match ? this.get(`src:${match[1]}`) : undefined;
|
|
101
|
+
if (meta) {
|
|
102
|
+
if (rows.length === MAX_SOURCES) throw new Error(`the registry exceeds ${MAX_SOURCES} sources; nothing was omitted silently (inspect ${this.root})`);
|
|
103
|
+
rows.push(meta);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return rows.sort((a, b) => a.added_at - b.added_at || a.id.localeCompare(b.id));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The stored bytes of a source, re-hashed: a snapshot that no longer matches its id is no source. */
|
|
110
|
+
readRaw(id: string): Buffer | undefined {
|
|
111
|
+
const meta = this.get(id);
|
|
112
|
+
if (!meta) return undefined;
|
|
113
|
+
try { const bytes = boundedRead(join(this.dir(id), "raw"), MAX_SOURCE_BYTES); return sha256Of(bytes) === meta.sha256 ? bytes : undefined; } catch { return undefined; }
|
|
114
|
+
}
|
|
115
|
+
readText(id: string): string | undefined {
|
|
116
|
+
if (!this.get(id)) return undefined;
|
|
117
|
+
try { return boundedRead(join(this.dir(id), "text"), MAX_SOURCE_TEXT).toString("utf8"); } catch { return undefined; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The registry-wide lock, and the only way a registration is serialized. It is published whole by `link(2)` of a fully written temp file (atomic, fails with
|
|
122
|
+
* EEXIST when held), so a lock file never exists without its owner's pid and process signature. It is removed only by its own holder, when the registration ends.
|
|
123
|
+
* It is NEVER removed on someone else's behalf: "check that the holder is gone, then unlink" is two steps, and a second recoverer that already passed the check
|
|
124
|
+
* would delete the first recoverer's fresh lock and run beside it, so a stale public write could overwrite a private upgrade. No filesystem call removes a file only
|
|
125
|
+
* if it is still the one that was inspected, so a lock whose holder is gone fails closed and the person repairs it (the error says how).
|
|
126
|
+
*/
|
|
127
|
+
private locked<T>(action: () => T): T {
|
|
128
|
+
const lock = join(this.root, ".lock"), temp = `${lock}.${crypto.randomUUID()}.tmp`;
|
|
129
|
+
const deadline = Date.now() + this.lockWaitMs;
|
|
130
|
+
writeFileSync(temp, JSON.stringify({ pid: process.pid, signature: processSignature(process.pid) ?? null }), { mode: 0o600, flag: "wx" });
|
|
131
|
+
let held = false;
|
|
132
|
+
try {
|
|
133
|
+
for (;;) {
|
|
134
|
+
try { linkSync(temp, lock); held = true; break; }
|
|
135
|
+
catch (error) {
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
137
|
+
let owner: { pid?: unknown; signature?: unknown } | undefined;
|
|
138
|
+
let observed: { dev: number; ino: number } | undefined, ownerText: string | undefined;
|
|
139
|
+
try {
|
|
140
|
+
const st = lstatSync(lock); observed = { dev: st.dev, ino: st.ino };
|
|
141
|
+
ownerText = readFileSync(lock, "utf8"); owner = JSON.parse(ownerText);
|
|
142
|
+
} catch { owner = undefined; }
|
|
143
|
+
const state = owner ? processLiveness(owner.pid, typeof owner.signature === "string" ? owner.signature : null) : "unknown";
|
|
144
|
+
if (state === "gone") {
|
|
145
|
+
// The normal holder may have released/replaced this lock while its
|
|
146
|
+
// process was inspected. Refuse only the same still-present record.
|
|
147
|
+
let unchanged = false;
|
|
148
|
+
try {
|
|
149
|
+
const current = lstatSync(lock);
|
|
150
|
+
unchanged = current.dev === observed?.dev && current.ino === observed?.ino && readFileSync(lock, "utf8") === ownerText;
|
|
151
|
+
} catch { /* released: retry acquisition without removing anything */ }
|
|
152
|
+
if (unchanged) throw new Error(`${lock} was left by a registration that is gone (pid ${String(owner?.pid)}); it is never removed automatically. Once no \`ahub source\` runs, delete that file and try again`);
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() >= deadline) throw new Error(`another ahub source registration holds ${lock}${owner ? ` (pid ${String(owner.pid)})` : ""}; try again in a moment`);
|
|
155
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} finally { try { unlinkSync(temp); } catch { /* the temp name is ours alone */ } }
|
|
159
|
+
try { return action(); }
|
|
160
|
+
finally { if (held) { try { unlinkSync(lock); } catch { /* already gone */ } } }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Host classification is monotonic and does not register a new origin or consume its cap. */
|
|
164
|
+
markPrivate(id: string): SourceMeta {
|
|
165
|
+
if (!validSourceId(id)) throw new Error("source classification unavailable");
|
|
166
|
+
return this.locked(() => {
|
|
167
|
+
const meta = this.get(id);
|
|
168
|
+
if (!meta) throw new Error("source classification unavailable");
|
|
169
|
+
if (meta.private) return meta;
|
|
170
|
+
const next = { ...meta, private: true };
|
|
171
|
+
atomic(join(this.dir(id), "meta.json"), JSON.stringify(next, null, 2) + "\n");
|
|
172
|
+
return next;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Register bytes. Equal bytes share one id, so a second registration returns the existing id. `private` is the OR over every registration of
|
|
178
|
+
* those bytes: re-adding from a private path, or with text the PII screen flags, upgrades the record; re-adding from a public path never
|
|
179
|
+
* downgrades it. Every origin is kept. `text` is the projection of the bytes (undefined when no converter handles them).
|
|
180
|
+
*/
|
|
181
|
+
add(input: { bytes: Buffer; kind: SourceKind; origin: string; private: boolean; text?: string; now?: number }): SourceAdd {
|
|
182
|
+
if (!Buffer.isBuffer(input.bytes)) throw new Error("a source is bytes");
|
|
183
|
+
if (input.text !== undefined && Buffer.byteLength(input.text) > MAX_SOURCE_TEXT) throw new Error(`the text is over the ${MAX_SOURCE_TEXT} byte cap; nothing was stored`);
|
|
184
|
+
if (input.bytes.length > MAX_SOURCE_BYTES) throw new Error(`source is ${input.bytes.length} bytes, over the ${MAX_SOURCE_BYTES} byte cap (MAX_PROJECTION_INPUT); nothing was stored`);
|
|
185
|
+
if (!cleanOrigin(input.origin)) throw new Error(`source origin must be 1 to ${MAX_ORIGIN_CHARS} printable characters`);
|
|
186
|
+
const sha256 = sha256Of(input.bytes), id = sourceId(sha256), now = input.now ?? Date.now(), directory = this.dir(id);
|
|
187
|
+
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
|
188
|
+
// One registration at a time per registry, across processes: the read of the record, the decision and the write are one step, so two CLIs adding the
|
|
189
|
+
// same bytes (one private, one public) can never each read the old flag and the later write lose the other's upgrade.
|
|
190
|
+
return this.locked(() => this.addLocked(input, sha256, id, now, directory));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private addLocked(input: { bytes: Buffer; kind: SourceKind; origin: string; private: boolean; text?: string }, sha256: string, id: string, now: number, directory: string): SourceAdd {
|
|
194
|
+
const existing = this.get(id);
|
|
195
|
+
// A directory that exists but whose record cannot be read is DAMAGED, not absent: replacing it could erase a private record and the bytes with it,
|
|
196
|
+
// so a damaged record is refused and left for the person, never silently rebuilt as public.
|
|
197
|
+
if (!existing && existsSync(join(directory, "meta.json"))) throw new Error(`the record for ${id} exists but cannot be read; it was left as it is (inspect ${directory})`);
|
|
198
|
+
if (existing) {
|
|
199
|
+
const origin: SourceOrigin = { kind: input.kind, origin: input.origin, added_at: now, private: input.private };
|
|
200
|
+
const known = existing.origins.some(item => item.kind === origin.kind && item.origin === origin.origin && item.private === origin.private);
|
|
201
|
+
if (!known && existing.origins.length >= MAX_ORIGINS) throw new Error(`the source holds ${MAX_ORIGINS} origins already; this registration was refused and the record was left unchanged`);
|
|
202
|
+
const privateNow = existing.private || input.private;
|
|
203
|
+
const origins = known ? existing.origins : [...existing.origins, origin];
|
|
204
|
+
if (privateNow === existing.private && origins === existing.origins) return { id, created: false, private: existing.private, upgraded: false, text: this.readText(id) !== undefined };
|
|
205
|
+
atomic(join(directory, "meta.json"), JSON.stringify({ ...existing, private: privateNow, origins }, null, 2) + "\n");
|
|
206
|
+
return { id, created: false, private: privateNow, upgraded: privateNow && !existing.private, text: this.readText(id) !== undefined };
|
|
207
|
+
}
|
|
208
|
+
// Count validated records, not directory entries or a target's mere existence.
|
|
209
|
+
// A crash partial at this id is still a NEW source and cannot bypass capacity.
|
|
210
|
+
if (this.list().length >= MAX_SOURCES) throw new Error(`the registry holds ${MAX_SOURCES} sources already; nothing was stored`);
|
|
211
|
+
if (existsSync(directory)) rmSync(directory, { recursive: true, force: true }); // no meta.json (checked above): a half-written directory of an earlier crash, never a record
|
|
212
|
+
const staging = `${directory}.${crypto.randomUUID()}.partial`;
|
|
213
|
+
try {
|
|
214
|
+
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
215
|
+
writeFileSync(join(staging, "raw"), input.bytes, { mode: 0o600 });
|
|
216
|
+
if (input.text !== undefined) writeFileSync(join(staging, "text"), input.text, { mode: 0o600 });
|
|
217
|
+
const meta: SourceMeta = { id, kind: input.kind, origin: input.origin, sha256, bytes: input.bytes.length, added_at: now, private: input.private, origins: [{ kind: input.kind, origin: input.origin, added_at: now, private: input.private }] };
|
|
218
|
+
writeFileSync(join(staging, "meta.json"), JSON.stringify(meta, null, 2) + "\n", { mode: 0o600 });
|
|
219
|
+
// The directory appears whole or not at all: a reader never sees raw without its meta.
|
|
220
|
+
renameSync(staging, directory);
|
|
221
|
+
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
222
|
+
return { id, created: true, private: input.private, upgraded: false, text: input.text !== undefined };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Anything a person could paste that is not a plain https URL is refused before a connection is made. */
|
|
227
|
+
export function checkSourceUrl(value: string): URL {
|
|
228
|
+
let url: URL;
|
|
229
|
+
try { url = new URL(value); } catch { throw new Error("not a URL"); }
|
|
230
|
+
if (url.protocol !== "https:") throw new Error("only https URLs can be registered");
|
|
231
|
+
if (url.username || url.password) throw new Error("a URL with credentials is refused");
|
|
232
|
+
if (!url.hostname) throw new Error("a URL needs a host");
|
|
233
|
+
return url;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type FetchLike = (input: string, init: { redirect: "manual"; signal: AbortSignal; headers: Record<string, string> }) => Promise<Response>;
|
|
237
|
+
/**
|
|
238
|
+
* Fetched once, by the person's CLI process (never a hub tool, agent or sandbox): https only, at most MAX_REDIRECTS redirects followed by hand (each one
|
|
239
|
+
* re-checked as https without credentials), and a running byte cap on the body, so a huge or endless response is cut off at the cap with the cap in the
|
|
240
|
+
* reason, never buffered whole and never truncated silently.
|
|
241
|
+
*/
|
|
242
|
+
export async function fetchSourceUrl(value: string, options: { fetch?: FetchLike; timeoutMs?: number; maxBytes?: number } = {}): Promise<{ bytes: Buffer; finalUrl: string; redirects: number }> {
|
|
243
|
+
const cap = options.maxBytes ?? MAX_SOURCE_BYTES, doFetch: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));
|
|
244
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
|
|
245
|
+
let url = checkSourceUrl(value), redirects = 0;
|
|
246
|
+
for (;;) {
|
|
247
|
+
const response = await doFetch(url.toString(), { redirect: "manual", signal, headers: { accept: "*/*", "user-agent": "agent-hub-source/1" } });
|
|
248
|
+
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
249
|
+
if (++redirects > MAX_REDIRECTS) throw new Error(`more than ${MAX_REDIRECTS} redirects`);
|
|
250
|
+
try { url = checkSourceUrl(new URL(response.headers.get("location")!, url).toString()); }
|
|
251
|
+
catch (error) { throw new Error(`redirect refused: ${(error as Error).message}`); }
|
|
252
|
+
await response.body?.cancel().catch(() => {});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (!response.ok) { await response.body?.cancel().catch(() => {}); throw new Error(`HTTP ${response.status}`); }
|
|
256
|
+
const declared = Number(response.headers.get("content-length"));
|
|
257
|
+
if (Number.isFinite(declared) && declared > cap) { await response.body?.cancel().catch(() => {}); throw new Error(`the response is ${declared} bytes, over the ${cap} byte cap (MAX_PROJECTION_INPUT); nothing was stored`); }
|
|
258
|
+
const chunks: Uint8Array[] = []; let total = 0;
|
|
259
|
+
const reader = response.body?.getReader();
|
|
260
|
+
if (!reader) throw new Error("the response has no body");
|
|
261
|
+
for (;;) {
|
|
262
|
+
const { done, value: chunk } = await reader.read();
|
|
263
|
+
if (done) break;
|
|
264
|
+
total += chunk.byteLength;
|
|
265
|
+
if (total > cap) { await reader.cancel().catch(() => {}); throw new Error(`the response is over the ${cap} byte cap (MAX_PROJECTION_INPUT); nothing was stored`); }
|
|
266
|
+
chunks.push(chunk);
|
|
267
|
+
}
|
|
268
|
+
return { bytes: Buffer.concat(chunks, total), finalUrl: url.toString(), redirects };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Why a registration is private, in fixed words: the reason a person reads, never the matched text. */
|
|
273
|
+
export type PrivateReason = "path under pii.paths" | "text matches signals.pii_patterns" | "text could not be screened";
|
|
274
|
+
export interface RegisterPolicy {
|
|
275
|
+
/** `pii.paths` of the project config. */
|
|
276
|
+
piiPaths: readonly string[];
|
|
277
|
+
/** `routing.signals.pii_patterns`: the same regular expressions the hub applies to task text. */
|
|
278
|
+
piiPatterns: readonly string[];
|
|
279
|
+
/** Project root the pii.paths are relative to. */
|
|
280
|
+
cwd: string;
|
|
281
|
+
/** The in-memory projection of the bytes (the existing text_projection converters), or undefined when none handles them. */
|
|
282
|
+
project?: (bytes: Buffer) => string | undefined;
|
|
283
|
+
matchesPolicy: (cwd: string, path: string, paths: string[]) => boolean;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* One decision, one place: whether a registration is private and what its text is. A path under `pii.paths` is private; so is bytes whose projected
|
|
287
|
+
* (or, for a plain text file, decoded) text matches a configured PII pattern. A pattern that cannot be evaluated counts as flagged, never as clear.
|
|
288
|
+
*/
|
|
289
|
+
export function screenRegistration(input: { bytes: Buffer; kind: SourceKind; path?: string; typedPath?: string }, policy: RegisterPolicy): { private: boolean; reasons: PrivateReason[]; text?: string } {
|
|
290
|
+
const reasons: PrivateReason[] = [];
|
|
291
|
+
// `path` is the resolved name and `typedPath` the one the person gave (#552, the #541 class): Bun's realpath can name another hardlink of the same file, so a name under pii.paths counts on either.
|
|
292
|
+
const named = [input.path, input.typedPath].filter((name): name is string => name !== undefined);
|
|
293
|
+
if (input.kind === "file" && named.some(name => policy.matchesPolicy(policy.cwd, name, [...policy.piiPaths]))) reasons.push("path under pii.paths");
|
|
294
|
+
let text: string | undefined;
|
|
295
|
+
try { text = policy.project?.(input.bytes); } catch { text = undefined; }
|
|
296
|
+
// A plain text file is its own text when no converter handles it and it is valid UTF-8 without NUL bytes; anything else has no text to screen.
|
|
297
|
+
if (text === undefined && !input.bytes.includes(0)) {
|
|
298
|
+
const decoded = new TextDecoder("utf-8", { fatal: false }).decode(input.bytes);
|
|
299
|
+
if (!decoded.includes("\uFFFD")) text = decoded;
|
|
300
|
+
}
|
|
301
|
+
if (text !== undefined && text.length) {
|
|
302
|
+
try { if (policy.piiPatterns.some(pattern => new RegExp(pattern).test(text!))) reasons.push("text matches signals.pii_patterns"); }
|
|
303
|
+
catch { reasons.push("text could not be screened"); }
|
|
304
|
+
}
|
|
305
|
+
return { private: reasons.length > 0, reasons, ...(text !== undefined ? { text } : {}) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The origin a registration is shown with: a private record shows none to anyone but the person (the CLI is the person). */
|
|
309
|
+
export const publicSourceView = (meta: SourceMeta): { id: string; kind: SourceKind; private: true } | SourceMeta => meta.private ? { id: meta.id, kind: meta.kind, private: true } : meta;
|
|
310
|
+
/** Existence check that never throws: used to tell a fixture from a corrupt directory. */
|
|
311
|
+
export const sourceDirExists = (stateDir: string, id: string): boolean => { try { return lstatSync(join(stateDir, "sources", dirName(id))).isDirectory(); } catch { return false; } };
|