@staix/agent-hub 0.20.0 → 0.22.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 +66 -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 +3 -3
- package/docs/agent-notes/daemon.md +17 -8
- 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 +39 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +610 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +133 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1101 -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/2026-10-30-0.22.0.md +49 -0
- package/docs/verification/2026-10-30-445-capability-probes.md +172 -0
- package/docs/verification/2026-10-30-bus-delivery-operational-settings.md +77 -0
- package/docs/verification/2026-10-30-jcode-ac4.md +24 -0
- package/docs/verification/2026-10-30-jcode-ac6.md +52 -0
- package/docs/verification/2026-10-30-recovery-wait-settings.md +141 -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 +29 -16
- package/src/cli/identity.ts +25 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/learn-output.ts +38 -0
- package/src/cli/main.ts +334 -73
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/recovery-waits.ts +26 -0
- 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 +82 -0
- package/src/cli/smoke-pty.ts +153 -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 +112 -28
- package/src/cli/upgrade.ts +68 -8
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +416 -65
- 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 +34 -6
- 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 +1209 -183
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +200 -73
- 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 +117 -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 +154 -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 +27 -2
- 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 +987 -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
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
2
|
+
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -26,6 +26,15 @@ export const budgetNow = (budget: SnapshotBudget): number => budget.now ? budget
|
|
|
26
26
|
export class SnapshotBudgetExceeded extends Error {
|
|
27
27
|
constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
|
|
28
28
|
}
|
|
29
|
+
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
30
|
+
/**
|
|
31
|
+
* The wall-clock timeout of one git child. Production (no `budget.now`): the remaining budget, between 1 ms and the 10 s ceiling, so a child is killed at the budget.
|
|
32
|
+
* ponytail: a host-held clock (`budget.now`, only the host-only snapshotClock seam sets it) holds the deadline, so the child's timeout is the 10 s ceiling, not the remaining budget:
|
|
33
|
+
* with the clock fixed the remaining budget never shrinks, and 250 ms of real time per child timed a slow git out under load (#554). Production never sets `now`, so its child is still killed
|
|
34
|
+
* at the budget. A child past the 10 s ceiling still reports ETIMEDOUT and the evidence stays unknown. Upgrade path: a fake spawn seam, if a held clock ever needs to model a slow child.
|
|
35
|
+
*/
|
|
36
|
+
export const childTimeoutMs = (budget?: SnapshotBudget): number =>
|
|
37
|
+
budget?.now ? SNAPSHOT_TIMEOUT_MS : Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS)));
|
|
29
38
|
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget, maxBuffer?: number, input?: string | Buffer) => {
|
|
30
39
|
const argv = repoGitArgs(top, args);
|
|
31
40
|
const remaining = budget ? budget.deadline - budgetNow(budget) : SNAPSHOT_TIMEOUT_MS;
|
|
@@ -33,12 +42,11 @@ const git = (top: string | Repo, args: string[], env: Record<string, string> = {
|
|
|
33
42
|
const result = hubGitSync(argv, {
|
|
34
43
|
encoding: "utf8", env: { ...repoGitEnv(top), ...env }, ...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
35
44
|
...(input === undefined ? {} : { input }),
|
|
36
|
-
timeout:
|
|
45
|
+
timeout: childTimeoutMs(budget),
|
|
37
46
|
});
|
|
38
47
|
if (budget && (budgetNow(budget) >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
|
|
39
48
|
return result;
|
|
40
49
|
};
|
|
41
|
-
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
42
50
|
|
|
43
51
|
/** A repository and where the project sits in it: `prefix` is "" at the top level, else "sub/dir/". */
|
|
44
52
|
export interface Repo {
|
|
@@ -543,6 +551,8 @@ export interface TurnRecord {
|
|
|
543
551
|
start_tree: string | null;
|
|
544
552
|
end_tree: string | null;
|
|
545
553
|
changed: string[];
|
|
554
|
+
/** Dirty submodules at the turn's end, whose contents nothing here observes (#798 D2). Absent: not recorded (an older turn, or git could not answer), which is unknown, never none. */
|
|
555
|
+
unobserved?: string[];
|
|
546
556
|
native: string | null;
|
|
547
557
|
}
|
|
548
558
|
|
|
@@ -606,6 +616,43 @@ export function dirtySubmodules(repo: Repo, budget?: SnapshotBudget): string[] |
|
|
|
606
616
|
return result.status === 0 ? parseDirtySubmodules(result.stdout) : undefined;
|
|
607
617
|
}
|
|
608
618
|
|
|
619
|
+
/**
|
|
620
|
+
* One git-config value as git reads it: an unquoted `#` or `;` starts a comment, a quoted run keeps them, quotes are dropped, and `\"` and `\\` are unescaped inside a quoted
|
|
621
|
+
* run. ponytail: other escapes (`\t`, octal) are kept as the character after the backslash; use `git config -f .gitmodules --get-regexp path` if a name needs them.
|
|
622
|
+
*/
|
|
623
|
+
function configValue(raw: string): string {
|
|
624
|
+
let out = "", keep = 0, quoted = false;
|
|
625
|
+
for (let i = 0; i < raw.length; i++) {
|
|
626
|
+
const c = raw[i]!;
|
|
627
|
+
if (quoted && c === "\\" && i + 1 < raw.length) { out += raw[++i]; keep = out.length; continue; }
|
|
628
|
+
if (c === '"') { quoted = !quoted; keep = out.length; continue; }
|
|
629
|
+
if (!quoted && (c === "#" || c === ";")) break;
|
|
630
|
+
if (!quoted && out.length === 0 && /\s/.test(c)) continue; // leading blanks
|
|
631
|
+
out += c;
|
|
632
|
+
if (quoted || !/\s/.test(c)) keep = out.length; // trailing blanks outside quotes are not part of the value
|
|
633
|
+
}
|
|
634
|
+
return out.slice(0, keep);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The submodule paths a project declares, relative to the project (#798 D1), read from `.gitmodules` with no git call. Empty without the file, and for a shadow
|
|
639
|
+
* repository. A path outside the project's own directory in a larger repository is not the project's. A path git quotes (`"a\tb"`) is taken as written, never
|
|
640
|
+
* unescaped beyond `\"` and `\\`: see `configValue`.
|
|
641
|
+
*/
|
|
642
|
+
export function submodulePaths(repo: Repo): string[] {
|
|
643
|
+
if (repo.shadow) return [];
|
|
644
|
+
let text: string;
|
|
645
|
+
try { text = readFileSync(join(repo.top, ".gitmodules"), "utf8"); } catch { return []; }
|
|
646
|
+
const found = new Set<string>();
|
|
647
|
+
for (const line of text.split(/\r?\n/)) {
|
|
648
|
+
const match = /^\s*path\s*=(.*)$/.exec(line);
|
|
649
|
+
let path = match ? configValue(match[1]!) : "";
|
|
650
|
+
path = path.replace(/\/+$/, "");
|
|
651
|
+
if (path && !path.startsWith("/") && !path.split("/").includes("..") && path.startsWith(repo.prefix)) found.add(path.slice(repo.prefix.length));
|
|
652
|
+
}
|
|
653
|
+
return [...found].sort();
|
|
654
|
+
}
|
|
655
|
+
|
|
609
656
|
/** Turn records in hub.db: the daemon writes them, `ahub turns` and `ahub undo` read them. */
|
|
610
657
|
export class Turns {
|
|
611
658
|
private readonly db: Database;
|
|
@@ -623,6 +670,7 @@ export class Turns {
|
|
|
623
670
|
this.db.run("CREATE TABLE IF NOT EXISTS touches (task INTEGER NOT NULL, peer TEXT NOT NULL, path TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (task, peer, path))");
|
|
624
671
|
this.db.run("CREATE INDEX IF NOT EXISTS touches_path ON touches (path)"); // the PreToolUse hook looks up one path
|
|
625
672
|
this.db.run("CREATE TABLE IF NOT EXISTS turn_workspaces (turn TEXT PRIMARY KEY, root TEXT NOT NULL, start_tree TEXT, end_tree TEXT, changed TEXT NOT NULL DEFAULT '[]')");
|
|
673
|
+
this.db.run("CREATE TABLE IF NOT EXISTS turn_unobserved (turn TEXT PRIMARY KEY, paths TEXT NOT NULL)");
|
|
626
674
|
this.db.run("CREATE TABLE IF NOT EXISTS task_snapshots (task INTEGER NOT NULL, proposed_at INTEGER NOT NULL, start_tree TEXT, PRIMARY KEY (task, proposed_at))");
|
|
627
675
|
}
|
|
628
676
|
/** A proposal's original project snapshot; task ids may be reused after reset. Never repair a missing baseline later. */
|
|
@@ -665,8 +713,9 @@ export class Turns {
|
|
|
665
713
|
native(id: string, native: string): void {
|
|
666
714
|
this.db.query("UPDATE turns SET native = ? WHERE id = ? AND native IS NULL").run(native, id);
|
|
667
715
|
}
|
|
668
|
-
end(id: string, endTree: string | undefined, changed: string[], keep: number): void {
|
|
716
|
+
end(id: string, endTree: string | undefined, changed: string[], keep: number, unobserved?: string[]): void {
|
|
669
717
|
this.db.transaction(() => {
|
|
718
|
+
if (unobserved !== undefined) this.db.query("INSERT OR REPLACE INTO turn_unobserved (turn, paths) VALUES (?, ?)").run(id, JSON.stringify(unobserved));
|
|
670
719
|
const scoped = this.workspace(id) !== undefined;
|
|
671
720
|
// Older CLIs do not know workspace receipts. Leave their canonical-checkout snapshot view unknown.
|
|
672
721
|
this.db.query("UPDATE turns SET ended = ?, end_tree = ?, changed = ? WHERE id = ?").run(Date.now(), scoped ? null : endTree ?? null, scoped ? "[]" : JSON.stringify(changed), id);
|
|
@@ -675,18 +724,35 @@ export class Turns {
|
|
|
675
724
|
const peer = (this.db.query("SELECT peer FROM turns WHERE id = ?").get(id) as { peer: string } | null)?.peer;
|
|
676
725
|
if (peer) this.db.query("DELETE FROM turns WHERE peer = ? AND id NOT IN (SELECT id FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?)").run(peer, peer, keep);
|
|
677
726
|
this.db.run("DELETE FROM turn_workspaces WHERE turn NOT IN (SELECT id FROM turns)");
|
|
727
|
+
this.db.run("DELETE FROM turn_unobserved WHERE turn NOT IN (SELECT id FROM turns)");
|
|
728
|
+
}
|
|
729
|
+
/** The dirty submodules recorded when the turn ended; undefined when none were recorded (unknown, not "none"). */
|
|
730
|
+
unobserved(id: string): string[] | undefined {
|
|
731
|
+
try {
|
|
732
|
+
const row = this.db.query("SELECT paths FROM turn_unobserved WHERE turn = ?").get(id) as { paths: string } | null;
|
|
733
|
+
if (!row) return undefined;
|
|
734
|
+
const paths = JSON.parse(row.paths);
|
|
735
|
+
return Array.isArray(paths) && paths.every(path => typeof path === "string") ? paths : undefined;
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (/no such table/.test((error as Error).message)) return undefined;
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
private withUnobserved<T extends { id: string }>(record: T): T & { unobserved?: string[] } {
|
|
742
|
+
const unobserved = this.unobserved(record.id);
|
|
743
|
+
return unobserved === undefined ? record : { ...record, unobserved };
|
|
678
744
|
}
|
|
679
745
|
list(peer?: string, limit = 20): TurnRecord[] {
|
|
680
746
|
const rows = (peer
|
|
681
747
|
? this.db.query("SELECT * FROM turns WHERE peer = ? ORDER BY started DESC, rowid DESC LIMIT ?").all(peer, limit)
|
|
682
748
|
: this.db.query("SELECT * FROM turns ORDER BY started DESC, rowid DESC LIMIT ?").all(limit)) as (Omit<TurnRecord, "changed"> & { changed: string })[];
|
|
683
|
-
return rows.map(row => this.snapshotFields(row)).map((r) => ({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
749
|
+
return rows.map(row => this.snapshotFields(row)).map((r) => this.withUnobserved({ ...r, changed: JSON.parse(r.changed) as string[] }));
|
|
684
750
|
}
|
|
685
751
|
get(id: string): TurnRecord | undefined {
|
|
686
752
|
const r = this.db.query("SELECT * FROM turns WHERE id = ?").get(id) as (Omit<TurnRecord, "changed"> & { changed: string }) | null;
|
|
687
753
|
if (!r) return undefined;
|
|
688
754
|
const record = this.snapshotFields(r);
|
|
689
|
-
return { ...record, changed: JSON.parse(record.changed) as string[] };
|
|
755
|
+
return this.withUnobserved({ ...record, changed: JSON.parse(record.changed) as string[] });
|
|
690
756
|
}
|
|
691
757
|
/**
|
|
692
758
|
* Other peers' turns that overlapped this one in time: their changes are in this turn's diff too. `unknown` lists
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
3
|
+
import { validSourceId } from "./sources.ts";
|
|
4
|
+
import type { SourceMeta } from "./sources.ts";
|
|
5
|
+
|
|
6
|
+
export const MAX_SOURCE_READ_BYTES = 32 * 1024;
|
|
7
|
+
const CUT_MARKER = " [cut]";
|
|
8
|
+
const REFUSAL = "source read unavailable";
|
|
9
|
+
|
|
10
|
+
export interface SourceReadRegistry {
|
|
11
|
+
get(id: string): SourceMeta | undefined;
|
|
12
|
+
readText(id: string): string | undefined;
|
|
13
|
+
markPrivate(id: string): SourceMeta | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SourceReadPolicy {
|
|
17
|
+
/** Screen the complete stored text. True means clear; false means private or unknown. */
|
|
18
|
+
screen(text: string, meta: SourceMeta): Promise<boolean>;
|
|
19
|
+
/** A synchronous, fresh authorization check. */
|
|
20
|
+
valid(): boolean;
|
|
21
|
+
/** True only for an authorized on-prem worker inside a PII task. */
|
|
22
|
+
canReadPrivate(): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SourceReadArgs { id: string; range?: { offset: number; length: number } }
|
|
26
|
+
export type SourceReadResult =
|
|
27
|
+
| { id: string; kind: SourceMeta["kind"]; private: true }
|
|
28
|
+
| { id: string; kind: SourceMeta["kind"]; private: true; text: string; next: number }
|
|
29
|
+
| { id: string; kind: SourceMeta["kind"]; private: false; text: string; next: number };
|
|
30
|
+
|
|
31
|
+
function refuse(): never { throw new PreEffectToolRefusal(REFUSAL); }
|
|
32
|
+
|
|
33
|
+
/** Validate the exact tool shape before touching the registry or privacy policy. */
|
|
34
|
+
export function validateSourceRead(value: unknown): SourceReadArgs {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) refuse();
|
|
36
|
+
const input = value as Record<string, unknown>;
|
|
37
|
+
if (Object.keys(input).some(key => key !== "id" && key !== "range") || !validSourceId(input.id)) refuse();
|
|
38
|
+
if (input.range === undefined) return { id: input.id };
|
|
39
|
+
if (!input.range || typeof input.range !== "object" || Array.isArray(input.range)) refuse();
|
|
40
|
+
const range = input.range as Record<string, unknown>;
|
|
41
|
+
if (Object.keys(range).length !== 2 || Object.keys(range).some(key => key !== "offset" && key !== "length")
|
|
42
|
+
|| !Number.isSafeInteger(range.offset) || (range.offset as number) < 0
|
|
43
|
+
|| !Number.isSafeInteger(range.length) || (range.length as number) < 1
|
|
44
|
+
|| !Number.isSafeInteger((range.offset as number) + (range.length as number))) refuse();
|
|
45
|
+
return { id: input.id, range: { offset: range.offset as number, length: range.length as number } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sameRecord(a: SourceMeta, b: SourceMeta): boolean {
|
|
49
|
+
return a.id === b.id && a.kind === b.kind && a.origin === b.origin && a.sha256 === b.sha256 && a.bytes === b.bytes
|
|
50
|
+
&& a.added_at === b.added_at && a.private === b.private && JSON.stringify(a.origins) === JSON.stringify(b.origins);
|
|
51
|
+
}
|
|
52
|
+
function sameContent(a: SourceMeta, b: SourceMeta): boolean {
|
|
53
|
+
return a.id === b.id && a.kind === b.kind && a.sha256 === b.sha256 && a.bytes === b.bytes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function privateResult(registry: SourceReadRegistry, id: string, meta: SourceMeta, range: SourceReadArgs["range"], policy: SourceReadPolicy): Promise<SourceReadResult> {
|
|
57
|
+
const authorized = await policy.canReadPrivate();
|
|
58
|
+
if (!policy.valid()) refuse();
|
|
59
|
+
const afterAuth = registry.get(id);
|
|
60
|
+
if (!afterAuth || !afterAuth.private || !sameRecord(meta, afterAuth)) refuse();
|
|
61
|
+
if (!authorized) {
|
|
62
|
+
if (!policy.valid()) refuse();
|
|
63
|
+
return { id, kind: afterAuth.kind, private: true };
|
|
64
|
+
}
|
|
65
|
+
const text = registry.readText(id), finalMeta = registry.get(id);
|
|
66
|
+
if (text === undefined || !finalMeta?.private || !sameRecord(afterAuth, finalMeta)) refuse();
|
|
67
|
+
if (!policy.valid()) refuse();
|
|
68
|
+
return render(id, finalMeta.kind, text, range, true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Screen the whole source before selecting a bounded excerpt; re-read identity and privacy after async policy boundaries. */
|
|
72
|
+
export async function sourceRead(registry: SourceReadRegistry, value: unknown, policy: SourceReadPolicy): Promise<SourceReadResult> {
|
|
73
|
+
const args = validateSourceRead(value);
|
|
74
|
+
try {
|
|
75
|
+
if (!policy.valid()) refuse();
|
|
76
|
+
let meta = registry.get(args.id);
|
|
77
|
+
if (!meta) refuse();
|
|
78
|
+
if (meta.private) {
|
|
79
|
+
const result = await privateResult(registry, args.id, meta, args.range, policy);
|
|
80
|
+
if (!policy.valid()) refuse();
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let fullText = registry.readText(args.id);
|
|
85
|
+
if (fullText === undefined) refuse();
|
|
86
|
+
let clear = false;
|
|
87
|
+
try { clear = await policy.screen(fullText, meta); }
|
|
88
|
+
catch { clear = false; }
|
|
89
|
+
if (!policy.valid()) refuse();
|
|
90
|
+
const afterScreen = registry.get(args.id), afterScreenText = registry.readText(args.id);
|
|
91
|
+
if (!afterScreen || afterScreenText === undefined || !sameContent(meta, afterScreen) || afterScreenText !== fullText) refuse();
|
|
92
|
+
if (afterScreen.private) {
|
|
93
|
+
const result = await privateResult(registry, args.id, afterScreen, args.range, policy);
|
|
94
|
+
if (!policy.valid()) refuse();
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
if (!sameRecord(meta, afterScreen)) refuse();
|
|
98
|
+
meta = afterScreen;
|
|
99
|
+
if (!clear) {
|
|
100
|
+
if (!policy.valid()) refuse();
|
|
101
|
+
meta = registry.markPrivate(args.id) ?? registry.get(args.id);
|
|
102
|
+
if (!meta?.private) refuse();
|
|
103
|
+
const result = await privateResult(registry, args.id, meta, args.range, policy);
|
|
104
|
+
if (!policy.valid()) refuse();
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!policy.valid()) refuse();
|
|
109
|
+
const current = registry.get(args.id);
|
|
110
|
+
if (!current || !sameRecord(meta, current) || current.private) refuse();
|
|
111
|
+
fullText = registry.readText(args.id);
|
|
112
|
+
if (fullText === undefined || fullText !== afterScreenText) refuse();
|
|
113
|
+
if (!policy.valid()) refuse();
|
|
114
|
+
return render(args.id, current.kind, fullText, args.range, false);
|
|
115
|
+
} catch {
|
|
116
|
+
refuse();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Walk only the requested, byte-bounded prefix. Sanitizer growth is measured by a bounded binary search over that prefix. */
|
|
121
|
+
function render(id: string, kind: SourceMeta["kind"], fullText: string, range: SourceReadArgs["range"], isPrivate: boolean): SourceReadResult {
|
|
122
|
+
const offset = range?.offset ?? 0, limit = range?.length ?? Number.MAX_SAFE_INTEGER;
|
|
123
|
+
const iterator = fullText[Symbol.iterator]();
|
|
124
|
+
let skipped = 0;
|
|
125
|
+
while (skipped < offset) {
|
|
126
|
+
if (iterator.next().done) return isPrivate
|
|
127
|
+
? { id, kind, private: true, text: "", next: offset }
|
|
128
|
+
: { id, kind, private: false, text: "", next: offset };
|
|
129
|
+
skipped++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const chars: string[] = [];
|
|
133
|
+
let rawBytes = 0, exhausted = false, stoppedAtByteLimit = false;
|
|
134
|
+
while (chars.length < limit) {
|
|
135
|
+
const next = iterator.next();
|
|
136
|
+
if (next.done) { exhausted = true; break; }
|
|
137
|
+
const point = next.value;
|
|
138
|
+
const size = Buffer.byteLength(point, "utf8");
|
|
139
|
+
if (rawBytes + size > MAX_SOURCE_READ_BYTES) { stoppedAtByteLimit = true; break; }
|
|
140
|
+
chars.push(point);
|
|
141
|
+
rawBytes += size;
|
|
142
|
+
}
|
|
143
|
+
const hasMoreAfterSelection = stoppedAtByteLimit || (!exhausted && chars.length >= limit && !iterator.next().done);
|
|
144
|
+
const fits = (count: number): boolean => {
|
|
145
|
+
const candidate = sanitize(chars.slice(0, count).join(""));
|
|
146
|
+
const needsMarker = hasMoreAfterSelection || count < chars.length;
|
|
147
|
+
return Buffer.byteLength(candidate, "utf8") + (needsMarker ? Buffer.byteLength(CUT_MARKER, "utf8") : 0) <= MAX_SOURCE_READ_BYTES;
|
|
148
|
+
};
|
|
149
|
+
let low = 0, high = chars.length;
|
|
150
|
+
while (low < high) {
|
|
151
|
+
const middle = Math.ceil((low + high) / 2);
|
|
152
|
+
if (fits(middle)) low = middle; else high = middle - 1;
|
|
153
|
+
}
|
|
154
|
+
const consumed = low;
|
|
155
|
+
let text = sanitize(chars.slice(0, consumed).join(""));
|
|
156
|
+
const hasMore = hasMoreAfterSelection || consumed < chars.length;
|
|
157
|
+
if (hasMore) text += CUT_MARKER;
|
|
158
|
+
const next = offset + consumed;
|
|
159
|
+
return isPrivate ? { id, kind, private: true, text, next } : { id, kind, private: false, text, next };
|
|
160
|
+
}
|
|
@@ -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; } };
|