@staix/agent-hub 0.12.23 → 0.12.24
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 +98 -0
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +2 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +651 -14
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +1 -1
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +282 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +910 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +892 -87
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +842 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { sbplString } from "../local/deny.ts";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { realPath } from "./project.ts";
|
|
9
|
+
import { profile, sandboxAvailable } from "../local/sandbox.ts";
|
|
10
|
+
export type ProjectionConfig = Record<string, string[]>;
|
|
11
|
+
export const MAX_PROJECTION_INPUT = 16 * 1024 * 1024;
|
|
12
|
+
const MAX_INPUT = MAX_PROJECTION_INPUT, MAX_TEXT = 256 * 1024;
|
|
13
|
+
/** Only machine-local config chooses programs; no implicit shell or command-string parsing. */
|
|
14
|
+
export function projectionConfig(raw: unknown): ProjectionConfig {
|
|
15
|
+
if (raw === undefined) return {};
|
|
16
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).length > 16) throw new Error("text_projection must be a bounded extension-to-argv map");
|
|
17
|
+
const out: ProjectionConfig = Object.create(null);
|
|
18
|
+
for (const [ext, argv] of Object.entries(raw)) {
|
|
19
|
+
if (!/^\.[a-z0-9]{1,16}$/.test(ext) || !Array.isArray(argv) || !argv.length || argv.length > 16 || argv.some(arg => typeof arg !== "string" || !arg || arg.length > 2000 || /[\x00-\x1f\x7f]/.test(arg)) || argv.filter(arg => arg === "{input}").length !== 1 || argv[0] === "{input}") throw new Error("text_projection entries require a bounded argv with one separate {input} argument");
|
|
20
|
+
out[ext] = [...argv];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export interface ProjectedText { inputHash: string; rawHash: string; rawBlob: string; configHash: string; text: string }
|
|
25
|
+
const hash = (data: string | Buffer) => createHash("sha256").update(data).digest("hex");
|
|
26
|
+
function commandFingerprint(cwd: string, argv: string[]): string {
|
|
27
|
+
const parts = [JSON.stringify(argv)];
|
|
28
|
+
for (const [index, arg] of argv.entries()) {
|
|
29
|
+
if (index !== 0 && !/\.(?:sh|py|js|ts|rb)$/.test(arg)) continue;
|
|
30
|
+
const choices = isAbsolute(arg) ? [arg] : index === 0 && !arg.includes("/") ? (process.env.PATH ?? "/usr/bin:/bin").split(":").slice(0, 32).map(dir => join(dir, arg)) : [resolve(cwd, arg)];
|
|
31
|
+
for (const file of choices) try { const st = statSync(file); if (!st.isFile()) continue; parts.push(file, String(st.size), String(st.mtimeMs), String(st.ctimeMs)); break; } catch { /* another PATH entry */ }
|
|
32
|
+
}
|
|
33
|
+
return hash(parts.join("\\n"));
|
|
34
|
+
}
|
|
35
|
+
function boundedCache(file: string): string {
|
|
36
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
37
|
+
try {
|
|
38
|
+
const st = fstatSync(fd);
|
|
39
|
+
if (!st.isFile() || st.nlink !== 1 || st.size > MAX_TEXT * 2) throw new Error("invalid cache file");
|
|
40
|
+
const bytes = Buffer.alloc(st.size); let got = 0;
|
|
41
|
+
while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
|
|
42
|
+
if (got !== st.size) throw new Error("cache changed");
|
|
43
|
+
return bytes.toString("utf8");
|
|
44
|
+
} finally { closeSync(fd); }
|
|
45
|
+
}
|
|
46
|
+
/** Projections are presentation evidence, never proof of who wrote the original bytes. */
|
|
47
|
+
export class TextProjections {
|
|
48
|
+
private readonly sandbox: string;
|
|
49
|
+
private readonly warned = new Set<string>();
|
|
50
|
+
constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
|
|
51
|
+
this.sandbox = Object.keys(options.config).length ? profile(options.cwd, false, options.readAllow, options.deny, ["."]) : "";
|
|
52
|
+
}
|
|
53
|
+
private warn(ext: string): void {
|
|
54
|
+
if (this.warned.has(ext)) return;
|
|
55
|
+
this.warned.add(ext); this.options.log?.("text projection " + ext + " unavailable; retained original facts behavior");
|
|
56
|
+
}
|
|
57
|
+
get enabled(): boolean { return Object.keys(this.options.config).length > 0; }
|
|
58
|
+
handles(file: string): boolean { return !!this.options.config[extname(file).toLowerCase()]; }
|
|
59
|
+
project(file: string): ProjectedText | undefined {
|
|
60
|
+
const ext = extname(file).toLowerCase(), argv = this.options.config[ext];
|
|
61
|
+
if (!argv) return undefined;
|
|
62
|
+
let fd: number | undefined;
|
|
63
|
+
try {
|
|
64
|
+
const root = realPath(this.options.cwd), input = realPath(file);
|
|
65
|
+
if (!input.startsWith(root + "/")) throw new Error("input outside project");
|
|
66
|
+
fd = openSync(input, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
67
|
+
const before = fstatSync(fd);
|
|
68
|
+
if (!before.isFile() || before.size > MAX_INPUT) throw new Error("input cap");
|
|
69
|
+
const bytes = Buffer.alloc(before.size); let got = 0;
|
|
70
|
+
while (got < bytes.length) { const n = readSync(fd, bytes, got, bytes.length - got, got); if (!n) break; got += n; }
|
|
71
|
+
const after = fstatSync(fd);
|
|
72
|
+
if (got !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("input changed");
|
|
73
|
+
return this.convert(ext, bytes, () => {
|
|
74
|
+
const final = fstatSync(fd!);
|
|
75
|
+
if (final.size !== before.size || final.mtimeMs !== before.mtimeMs) throw new Error("input changed during conversion");
|
|
76
|
+
});
|
|
77
|
+
} catch { this.warn(ext); return undefined; }
|
|
78
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
79
|
+
}
|
|
80
|
+
/** Historic raw bytes; optional deadline uses performance.now(), shared across the preview. No current file read. */
|
|
81
|
+
projectBytes(file: string, bytes: Buffer, deadline?: number): ProjectedText | undefined {
|
|
82
|
+
const ext = extname(file).toLowerCase();
|
|
83
|
+
if (!this.options.config[ext]) return undefined;
|
|
84
|
+
try {
|
|
85
|
+
const root = realPath(this.options.cwd), input = resolve(root, file);
|
|
86
|
+
if (!input.startsWith(root + "/") || !Buffer.isBuffer(bytes) || bytes.length > MAX_INPUT) throw new Error("snapshot input cap or path");
|
|
87
|
+
return this.convert(ext, Buffer.from(bytes), undefined, deadline);
|
|
88
|
+
} catch { this.warn(ext); return undefined; }
|
|
89
|
+
}
|
|
90
|
+
private convert(ext: string, bytes: Buffer, verify?: () => void, deadline?: number): ProjectedText | undefined {
|
|
91
|
+
const argv = this.options.config[ext];
|
|
92
|
+
if (!argv) return undefined;
|
|
93
|
+
let scratch: string | undefined;
|
|
94
|
+
try {
|
|
95
|
+
if (deadline !== undefined && (!Number.isFinite(deadline) || deadline <= performance.now())) throw new Error("projection deadline");
|
|
96
|
+
const rawHash = createHash("sha1").update(bytes).digest("hex"), rawBlob = createHash("sha1").update("blob " + bytes.length + "\0").update(bytes).digest("hex");
|
|
97
|
+
const inputHash = hash(bytes), configHash = commandFingerprint(this.options.cwd, argv), key = hash(inputHash + "\n" + configHash + "\n" + ext);
|
|
98
|
+
const cacheDir = join(this.options.stateDir, "text-projections"), cache = join(cacheDir, key + ".json");
|
|
99
|
+
try {
|
|
100
|
+
const cached = boundedCache(cache);
|
|
101
|
+
if (Buffer.byteLength(cached) <= MAX_TEXT * 2) {
|
|
102
|
+
const value = JSON.parse(cached) as ProjectedText;
|
|
103
|
+
if (value.inputHash === inputHash && value.rawHash === rawHash && value.rawBlob === rawBlob && value.configHash === configHash && typeof value.text === "string" && Buffer.byteLength(value.text) <= MAX_TEXT) return value;
|
|
104
|
+
}
|
|
105
|
+
} catch { /* missing/invalid cache is not evidence */ }
|
|
106
|
+
if (!sandboxAvailable()) throw new Error("sandbox unavailable");
|
|
107
|
+
scratch = realPath(mkdtempSync(join(tmpdir(), "ahub-projection-input-"))); const snapshot = join(scratch, "input" + ext);
|
|
108
|
+
writeFileSync(snapshot, bytes, { mode: 0o400 });
|
|
109
|
+
const result = spawnSync(process.execPath, [join(import.meta.dir, "projection-exec.ts")], {
|
|
110
|
+
cwd: this.options.cwd, timeout: deadline === undefined ? 8000 : Math.ceil(Math.max(1, Math.min(8000, deadline - performance.now()))), maxBuffer: MAX_TEXT * 2, encoding: "utf8",
|
|
111
|
+
input: JSON.stringify({ cwd: this.options.cwd, sandbox: this.sandbox + "\n(allow file-read* (literal " + sbplString(snapshot) + "))", argv: argv.map(arg => arg === "{input}" ? snapshot : arg), ...(deadline === undefined ? {} : { timeoutMs: Math.max(1, Math.min(5000, deadline - performance.now() - 100)) }) }),
|
|
112
|
+
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: "en_US.UTF-8", TERM: "dumb" },
|
|
113
|
+
});
|
|
114
|
+
if (result.error || result.status !== 0) throw new Error("conversion failed");
|
|
115
|
+
const converted = JSON.parse(result.stdout) as { ok?: boolean; text?: string };
|
|
116
|
+
if (converted.ok !== true || typeof converted.text !== "string" || Buffer.byteLength(converted.text) > MAX_TEXT || converted.text.includes("\0")) throw new Error("conversion failed");
|
|
117
|
+
verify?.();
|
|
118
|
+
const value = { inputHash, rawHash, rawBlob, configHash, text: converted.text };
|
|
119
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
120
|
+
const temporary = cache + "." + crypto.randomUUID() + ".tmp";
|
|
121
|
+
writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 }); renameSync(temporary, cache);
|
|
122
|
+
return value;
|
|
123
|
+
} catch { this.warn(ext); return undefined; }
|
|
124
|
+
finally { if (scratch) rmSync(scratch, { recursive: true, force: true }); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, join, relative, resolve, isAbsolute } from "node:path";
|
|
5
|
+
import { realPath } from "./project.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #248: which add-ons a peer works with, from a closed list. Names and versions only, read from the machine's plugin
|
|
9
|
+
* inventory and launch settings (Claude Code's installed plugins and `enabledPlugins`, Codex's `config.toml`) and from
|
|
10
|
+
* the executables on PATH; never a plugin's content, settings values, hooks or paths.
|
|
11
|
+
* ponytail: the inventory is the machine's, not the session's: a session started with its own `--settings` or plugin
|
|
12
|
+
* flags can differ. Reading the launch record of each `ahub claude` / `ahub codex` would make it exact.
|
|
13
|
+
*/
|
|
14
|
+
export const ADDONS = ["claude-mem", "ponytail", "ripwire", "open-code-review", "codex-plugin", "dot-admit"] as const;
|
|
15
|
+
export type AddonName = (typeof ADDONS)[number];
|
|
16
|
+
export interface Addon { name: AddonName; version: string | null }
|
|
17
|
+
|
|
18
|
+
/** Plugin names (before the `@marketplace`) that stand for an add-on. */
|
|
19
|
+
// Maps, not objects: a plugin named `constructor` or `toString` must find nothing, not an inherited property.
|
|
20
|
+
const PLUGINS = new Map<string, AddonName>([["claude-mem", "claude-mem"], ["ponytail", "ponytail"], ["open-code-review", "open-code-review"]]);
|
|
21
|
+
/** Claude Code only: the Codex plugin is a Claude plugin named `codex` from the `openai-codex` marketplace. */
|
|
22
|
+
const CLAUDE_ONLY = new Map<string, AddonName>([["codex@openai-codex", "codex-plugin"]]);
|
|
23
|
+
/**
|
|
24
|
+
* Executables any peer with a shell can run, and how each is told apart from another program of the same name: the
|
|
25
|
+
* first one on PATH is asked for its version (bounded, no stdin), so Graphviz's `dot` is never resource admission.
|
|
26
|
+
*/
|
|
27
|
+
const BINARIES: Record<string, { name: AddonName; version?: RegExp }> = { ripwire: { name: "ripwire" }, dot: { name: "dot-admit", version: /^dot version (\S+)/ } };
|
|
28
|
+
/** By resolved path and modification time: one version call per binary per process. */
|
|
29
|
+
const identities = new Map<string, Promise<string | null | false>>();
|
|
30
|
+
const PROBE_MS = 2000, PROBE_BYTES = 4096;
|
|
31
|
+
|
|
32
|
+
/** `env` supplies CLAUDE_CONFIG_DIR and CODEX_HOME, the runtimes' own config roots (the hub's environment by default). */
|
|
33
|
+
export interface ToolingSources { home?: string; cwd?: string; path?: string; env?: Record<string, string | undefined> }
|
|
34
|
+
|
|
35
|
+
const json = (file: string): Record<string, unknown> | undefined => {
|
|
36
|
+
try { const v = JSON.parse(readFileSync(file, "utf8")); return v && typeof v === "object" ? v as Record<string, unknown> : undefined; } catch { return undefined; }
|
|
37
|
+
};
|
|
38
|
+
/** A version string as data, never anything longer or stranger. */
|
|
39
|
+
const version = (v: unknown): string | null => typeof v === "string" && /^[0-9A-Za-z.+_-]{1,40}$/.test(v) ? v : null;
|
|
40
|
+
|
|
41
|
+
function claudePlugins(root: string, cwd: string | undefined): Addon[] {
|
|
42
|
+
const enabled = new Map<string, boolean>();
|
|
43
|
+
for (const file of [join(root, "settings.json"), ...(cwd ? [join(cwd, ".claude", "settings.json"), join(cwd, ".claude", "settings.local.json")] : [])]) {
|
|
44
|
+
const map = json(file)?.enabledPlugins;
|
|
45
|
+
if (map && typeof map === "object") for (const [key, on] of Object.entries(map)) enabled.set(key, on === true);
|
|
46
|
+
}
|
|
47
|
+
const installed = (json(join(root, "plugins", "installed_plugins.json"))?.plugins ?? {}) as Record<string, { scope?: string; projectPath?: string; version?: unknown }[]>;
|
|
48
|
+
const out: Addon[] = [];
|
|
49
|
+
for (const [key, on] of enabled) {
|
|
50
|
+
if (!on) continue;
|
|
51
|
+
const name = CLAUDE_ONLY.get(key) ?? PLUGINS.get(key.split("@")[0]!);
|
|
52
|
+
const entries = Array.isArray(installed[key]) ? installed[key]! : [];
|
|
53
|
+
// An install for another project is not this one's.
|
|
54
|
+
const entry = entries.find((e) => e?.scope === "user") ?? entries.find((e) => e?.projectPath === cwd);
|
|
55
|
+
if (name && entry) out.push({ name, version: version(entry.version) });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function codexPlugins(root: string): Addon[] {
|
|
61
|
+
let text: string;
|
|
62
|
+
try { text = readFileSync(join(root, "config.toml"), "utf8"); } catch { return []; }
|
|
63
|
+
const found = new Map<string, string>(); // plugin name -> marketplace
|
|
64
|
+
// `[plugins."name@market"]` with no `enabled = false` below it, and plugins whose hooks Codex was told to trust.
|
|
65
|
+
for (const m of text.matchAll(/^\[plugins\."([^"@]+)@([^"]+)"\]\s*\n((?:(?!\[)[^\n]*\n?)*)/gm)) if (!/^\s*enabled\s*=\s*false\b/m.test(m[3] ?? "")) found.set(m[1]!, m[2]!);
|
|
66
|
+
for (const m of text.matchAll(/^\[hooks\.state\."([^"@]+)@([^":]+):/gm)) found.set(m[1]!, m[2]!);
|
|
67
|
+
const out: Addon[] = [];
|
|
68
|
+
for (const [plugin, market] of found) {
|
|
69
|
+
const name = PLUGINS.get(plugin);
|
|
70
|
+
if (!name) continue;
|
|
71
|
+
let versions: string[] = [];
|
|
72
|
+
try { versions = readdirSync(join(root, "plugins", "cache", market, plugin)); } catch { /* no cache: version unknown */ }
|
|
73
|
+
out.push({ name, version: versions.length === 1 ? version(versions[0]) : null });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Never throws: a path that cannot be resolved (a project directory that went away) is compared as given. */
|
|
79
|
+
const real = (path: string): string => { try { return realPath(path); } catch { return resolve(path); } };
|
|
80
|
+
const within = (file: string, dir: string): boolean => { const r = relative(dir, file); return r === "" || (!r.startsWith("..") && !isAbsolute(r)); };
|
|
81
|
+
|
|
82
|
+
/** The first executable of that name on PATH, relative and empty entries resolved from the peer's working directory. */
|
|
83
|
+
function onPath(name: string, path: string, cwd: string): string | undefined {
|
|
84
|
+
for (const dir of path.split(delimiter)) {
|
|
85
|
+
const file = resolve(cwd, dir || ".", name);
|
|
86
|
+
// A shell runs the first executable regular file; a directory of that name is skipped like a missing one.
|
|
87
|
+
try { if (statSync(file).isFile()) { accessSync(file, constants.X_OK); return file; } } catch { /* not here */ }
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The version an executable reports when it is the expected program, `null` when that is unknown, `false` when it is
|
|
93
|
+
* another program or does not answer within PROBE_MS. Asynchronous with a hard deadline: a program that ignores SIGTERM
|
|
94
|
+
* is killed outright and never waited on, so it can never hold up the hub.
|
|
95
|
+
*/
|
|
96
|
+
function identify(file: string, pattern: RegExp, env: Record<string, string>): Promise<string | null | false> {
|
|
97
|
+
let key = file;
|
|
98
|
+
try { key = `${file}:${statSync(file).mtimeMs}`; } catch { /* unreadable: ask anyway */ }
|
|
99
|
+
let found = identities.get(key);
|
|
100
|
+
if (!found) {
|
|
101
|
+
found = new Promise<string | null | false>((resolve) => {
|
|
102
|
+
let out = "", settled = false, child: ChildProcess | undefined;
|
|
103
|
+
// Its own process group, killed as one when the answer is in or the deadline passes: a descendant that kept the
|
|
104
|
+
// pipe open would otherwise outlive the probe and hold up whoever waits on it.
|
|
105
|
+
const finish = (answer: string | null | false) => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
try { if (child?.pid) process.kill(-child.pid, "SIGKILL"); } catch { /* the group is gone */ }
|
|
110
|
+
child?.stdout?.destroy();
|
|
111
|
+
resolve(answer);
|
|
112
|
+
};
|
|
113
|
+
const timer = setTimeout(() => finish(false), PROBE_MS);
|
|
114
|
+
try {
|
|
115
|
+
// Only PATH (its absolute entries outside the project, so an env-shebang interpreter still resolves), HOME and
|
|
116
|
+
// the C locale reach the probed program: no gateway keys, Access values or agent markers.
|
|
117
|
+
child = spawn(file, ["--version"], { detached: true, stdio: ["ignore", "pipe", "ignore"], env });
|
|
118
|
+
child.unref();
|
|
119
|
+
// A version line is short: a program that keeps writing is not the one asked for, and is not read further.
|
|
120
|
+
child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString("utf8"); if (out.length > PROBE_BYTES) finish(false); });
|
|
121
|
+
child.on("error", () => finish(false));
|
|
122
|
+
// The version line is written before the program exits; a moment for the pipe to deliver it.
|
|
123
|
+
child.on("exit", () => setTimeout(() => { const m = pattern.exec(out.trim()); finish(m ? version(m[1]) : false); }, 50));
|
|
124
|
+
} catch { finish(false); }
|
|
125
|
+
});
|
|
126
|
+
identities.set(key, found);
|
|
127
|
+
}
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The add-ons a peer works with, sorted by name. Unreadable sources contribute nothing; they never throw. */
|
|
132
|
+
export async function detectTooling(peer: string, src: ToolingSources = {}): Promise<Addon[]> {
|
|
133
|
+
const home = src.home ?? homedir();
|
|
134
|
+
const found = new Map<AddonName, Addon>();
|
|
135
|
+
const add = (a: Addon) => { if (!found.has(a.name) || (found.get(a.name)!.version === null && a.version !== null)) found.set(a.name, a); };
|
|
136
|
+
const env = src.env ?? process.env;
|
|
137
|
+
if (peer === "claude") claudePlugins(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), src.cwd).forEach(add);
|
|
138
|
+
if (peer === "codex") codexPlugins(env.CODEX_HOME?.trim() || join(home, ".codex")).forEach(add);
|
|
139
|
+
const path = src.path ?? process.env.PATH ?? "";
|
|
140
|
+
for (const [bin, spec] of Object.entries(BINARIES)) {
|
|
141
|
+
const file = onPath(bin, path, src.cwd ?? process.cwd());
|
|
142
|
+
if (!file) continue;
|
|
143
|
+
// A program inside the project is the repository's, not an installed tool: it is never run to identify it (opening a
|
|
144
|
+
// checkout must not execute its files), so it does not count where its identity has to be checked.
|
|
145
|
+
const inProject = src.cwd !== undefined && within(real(file), real(src.cwd));
|
|
146
|
+
const probeEnv = { PATH: path.split(delimiter).filter((d) => isAbsolute(d) && !(src.cwd !== undefined && within(real(d), real(src.cwd)))).join(delimiter) || "/usr/bin:/bin", HOME: home, LANG: "C", LC_ALL: "C" };
|
|
147
|
+
const v = spec.version ? (inProject ? false : await identify(file, spec.version, probeEnv)) : null;
|
|
148
|
+
if (v !== false) add({ name: spec.name, version: v });
|
|
149
|
+
}
|
|
150
|
+
return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `on` when any of the peers has the add-on, `off` when every peer's fingerprint is known and none has it, else unknown. */
|
|
154
|
+
export function addonState(name: AddonName, fingerprints: (Addon[] | undefined)[]): "on" | "off" | "unknown" {
|
|
155
|
+
if (fingerprints.some((f) => f?.some((a) => a.name === name))) return "on";
|
|
156
|
+
return fingerprints.length && fingerprints.every((f) => f !== undefined) ? "off" : "unknown";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const isAddon = (name: string): name is AddonName => (ADDONS as readonly string[]).includes(name);
|
package/src/hub/ui.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
|
|
2
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
|
+
import { boardColumns } from "../ui/task-board.ts";
|
|
4
|
+
import { PEER_PALETTE, peerPaletteCss } from "../ui/peer-palette.ts";
|
|
1
5
|
import { PEER_START_RETRY_MS } from "./start-mode.ts";
|
|
2
6
|
import { taskProgress } from "../ui/task-progress.ts";
|
|
7
|
+
import { officeModel } from "../ui/office-model.ts";
|
|
8
|
+
import { needs } from "../ui/needs.ts";
|
|
9
|
+
import { CHARACTERS } from "../pet/characters.ts";
|
|
10
|
+
import { petPixels } from "../pet/pixels.ts";
|
|
3
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
12
|
import { readFileSync } from "node:fs";
|
|
5
13
|
|
|
@@ -28,8 +36,12 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
28
36
|
const sessions = new Map<string, { expires: number; settingsUntil?: number }>();
|
|
29
37
|
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
30
38
|
const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
|
|
39
|
+
.replace("/* BOARD_MODEL */", () => `const boardColumns = ${boardColumns.toString()}; const peerPalette = ${JSON.stringify(PEER_PALETTE)};`)
|
|
40
|
+
.replace("/* PEER_PALETTE_CSS */", () => peerPaletteCss())
|
|
31
41
|
.replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`)
|
|
32
|
-
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
|
|
42
|
+
.replace("/* PEER_START_RETRY_MS */", () => `const PEER_START_RETRY_MS = ${PEER_START_RETRY_MS};`)
|
|
43
|
+
.replace("/* OFFICE_MODEL */", () => `const officeModel = ${officeModel.toString()};`)
|
|
44
|
+
.replace("/* PET_MODEL */", () => `const needs = ${needs.toString()}; const petCharacters = ${JSON.stringify(Object.fromEntries(Object.entries(CHARACTERS).map(([id, character]) => [id, { name: character.name, accent: character.accent, frames: { blocks: Object.fromEntries(Object.entries(character.frames.blocks).map(([pose, sizes]) => [pose, { full: sizes.full }])) } }])))}; const petPixels = ${petPixels.toString()};`);
|
|
33
45
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
34
46
|
.map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
|
|
35
47
|
const headers = {
|
|
@@ -53,7 +65,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
53
65
|
hostname: "127.0.0.1",
|
|
54
66
|
port: 0,
|
|
55
67
|
maxRequestBodySize: 32 * 1024,
|
|
56
|
-
async fetch(req): Promise<Response> {
|
|
68
|
+
async fetch(req, server): Promise<Response> {
|
|
57
69
|
if (req.headers.get("host") !== host) return reject(403, "forbidden host");
|
|
58
70
|
const requestOrigin = req.headers.get("origin");
|
|
59
71
|
if (requestOrigin !== null && requestOrigin !== origin) return reject(403, "forbidden origin");
|
|
@@ -121,6 +133,8 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
135
|
try {
|
|
136
|
+
if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
|
|
137
|
+
else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
|
|
124
138
|
return json(await options.action(input, sessionOf(cookie)));
|
|
125
139
|
} catch {
|
|
126
140
|
// Exceptions may quote private task text or backend details. Never echo them to the page.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Task } from "./board.ts";
|
|
3
|
+
import type { SdlcConfig } from "./sdlc.ts";
|
|
4
|
+
|
|
5
|
+
export interface PhaseReceipt {
|
|
6
|
+
id: string;
|
|
7
|
+
head?: string;
|
|
8
|
+
tasks: { id: number; after: number[] }[];
|
|
9
|
+
criteria: { id: string; required: boolean; result: "verified" | "failed" | "unverified"; refs: string[] }[];
|
|
10
|
+
}
|
|
11
|
+
const taskId = (n: unknown): n is number => Number.isSafeInteger(n) && typeof n === "number" && n > 0;
|
|
12
|
+
const name = (s: unknown): s is string => typeof s === "string" && /^[a-z][a-z0-9-]{0,47}$/.test(s);
|
|
13
|
+
const hash = (s: unknown): s is string => typeof s === "string" && /^[0-9a-f]{40}$/.test(s);
|
|
14
|
+
const fields = (v: unknown, keys: string[], label: string): Record<string, unknown> => {
|
|
15
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !keys.includes(k))) throw new Error(`${label}: invalid fields`);
|
|
16
|
+
return v as Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
export function phaseReceipt(value: unknown): PhaseReceipt {
|
|
19
|
+
const serialized = JSON.stringify(value);
|
|
20
|
+
if (typeof serialized !== "string") throw new Error("phase: invalid fields");
|
|
21
|
+
if (Buffer.byteLength(serialized) > 32768) throw new Error("phase: receipt exceeds 32 KiB");
|
|
22
|
+
const p = fields(value, ["id", "head", "tasks", "criteria"], "phase");
|
|
23
|
+
if (!name(p.id) || (p.head !== undefined && !hash(p.head))) throw new Error("phase: expected bounded id and full head SHA");
|
|
24
|
+
if (!Array.isArray(p.tasks) || p.tasks.length > 64 || !Array.isArray(p.criteria) || !p.criteria.length || p.criteria.length > 32) throw new Error("phase: at most 64 tasks and 1..32 criteria");
|
|
25
|
+
const tasks = p.tasks.map(raw => {
|
|
26
|
+
const t = fields(raw, ["id", "after"], "phase task");
|
|
27
|
+
if (!taskId(t.id) || !Array.isArray(t.after) || t.after.length > 16 || !t.after.every(taskId) || new Set(t.after).size !== t.after.length || t.after.includes(t.id)) throw new Error("phase task: invalid id or predecessors");
|
|
28
|
+
return { id: t.id, after: [...t.after] as number[] };
|
|
29
|
+
});
|
|
30
|
+
const criteria = p.criteria.map(raw => {
|
|
31
|
+
const c = fields(raw, ["id", "required", "result", "refs"], "phase criterion");
|
|
32
|
+
if (!name(c.id) || typeof c.required !== "boolean" || !["verified", "failed", "unverified"].includes(String(c.result)) || !Array.isArray(c.refs) || c.refs.length > 8 || new Set(c.refs).size !== c.refs.length || c.refs.some(ref => typeof ref !== "string" || !/^(sha256:[0-9a-f]{64}|(artifact|check|source):[A-Za-z0-9._/-]{1,160}@[0-9a-f]{64})$/.test(ref)) || (c.result === "verified" && !c.refs.length)) throw new Error("phase criterion: invalid outcome or hashed evidence refs");
|
|
33
|
+
return { id: c.id, required: c.required, result: c.result as PhaseReceipt["criteria"][number]["result"], refs: [...c.refs] as string[] };
|
|
34
|
+
});
|
|
35
|
+
if (new Set(tasks.map(t => t.id)).size !== tasks.length || new Set(criteria.map(c => c.id)).size !== criteria.length) throw new Error("phase: duplicate task or criterion");
|
|
36
|
+
return { id: p.id, ...(p.head ? { head: p.head as string } : {}), tasks, criteria };
|
|
37
|
+
}
|
|
38
|
+
export type TaskLookup = (id: number) => Task | null | undefined;
|
|
39
|
+
/** An import names existing Tasks.after edges, it never edits dependencies or ownership. */
|
|
40
|
+
export function validatePhaseLinks(parent: Task, phase: PhaseReceipt, get: TaskLookup, isPrivate: (t: Task) => boolean): void {
|
|
41
|
+
const seen = new Set<number>(), active = new Set<number>();
|
|
42
|
+
const visit = (id: number): void => {
|
|
43
|
+
if (id === parent.id) throw new Error("phase: parent cannot be its own prerequisite or child");
|
|
44
|
+
if (active.has(id)) throw new Error("phase: dependency cycle");
|
|
45
|
+
if (seen.has(id)) return;
|
|
46
|
+
if (seen.size >= 256) throw new Error("phase: dependency closure exceeds 256 tasks");
|
|
47
|
+
const task = get(id); if (!task) throw new Error("phase: referenced task missing");
|
|
48
|
+
if (!isPrivate(parent) && isPrivate(task)) throw new Error("phase: ordinary parent cannot reference private work");
|
|
49
|
+
seen.add(id); active.add(id);
|
|
50
|
+
for (const predecessor of task.deps ?? []) visit(predecessor);
|
|
51
|
+
active.delete(id);
|
|
52
|
+
};
|
|
53
|
+
for (const link of phase.tasks) {
|
|
54
|
+
visit(link.id);
|
|
55
|
+
const actual = get(link.id)!.deps ?? [];
|
|
56
|
+
if (actual.length !== link.after.length || actual.some(id => !link.after.includes(id))) throw new Error("phase: predecessor ids must match current Tasks.after edges");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export interface PhaseView { workflow: "gsd-lite"; id?: string; ready: boolean; blockers: string[]; tasks?: { id: number; after: number[]; state: string }[]; criteria?: PhaseReceipt["criteria"]; }
|
|
60
|
+
export function phaseView(config: SdlcConfig, parent: Task, get: TaskLookup, isPrivate: (t: Task) => boolean): PhaseView | undefined {
|
|
61
|
+
if (config.workflow !== "gsd-lite") return;
|
|
62
|
+
const blockers: string[] = [], phase = parent.sdlc?.phase;
|
|
63
|
+
if (isPrivate(parent)) return { workflow: "gsd-lite", ready: false, blockers: ["private phase evidence unknown"] };
|
|
64
|
+
if (!phase) return { workflow: "gsd-lite", ready: false, blockers: ["phase checklist missing"] };
|
|
65
|
+
try { validatePhaseLinks(parent, phase, get, isPrivate); } catch { return { workflow: "gsd-lite", id: phase.id, ready: false, blockers: ["phase dependency evidence changed or unavailable"] }; }
|
|
66
|
+
for (const criterion of phase.criteria) if (criterion.required && criterion.result !== "verified") blockers.push(`phase criterion ${criterion.id}: ${criterion.result}`);
|
|
67
|
+
const tasks = phase.tasks.map(link => ({ id: link.id, after: [...(get(link.id)?.deps ?? [])], state: get(link.id)?.state ?? "unknown" }));
|
|
68
|
+
if (tasks.some(task => task.state !== "approved")) blockers.push("phase child tasks not approved");
|
|
69
|
+
return { workflow: "gsd-lite", id: phase.id, ready: blockers.length === 0, blockers, tasks, criteria: phase.criteria.map(c => ({ ...c, refs: [...c.refs] })) };
|
|
70
|
+
}
|
|
71
|
+
export function workflowHash(value: unknown): string { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
package/src/local/sandbox.ts
CHANGED
|
@@ -105,7 +105,7 @@ export type SandboxNetwork = boolean | { proxyPort: number };
|
|
|
105
105
|
* toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
|
|
106
106
|
* 0.9 and earlier was removed in 0.12.0 (issue #83).
|
|
107
107
|
*/
|
|
108
|
-
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
|
|
108
|
+
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = [], readOnly: string[] = []): string {
|
|
109
109
|
const home = homedir();
|
|
110
110
|
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
111
111
|
const root = realPath(cwd);
|
|
@@ -138,7 +138,7 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
138
138
|
"(version 1)",
|
|
139
139
|
...start,
|
|
140
140
|
"(deny file-write*)",
|
|
141
|
-
`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})
|
|
141
|
+
...(readOnly.length ? ["(allow file-write* (literal \"/dev/null\"))"] : [`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`]),
|
|
142
142
|
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub. The .git and
|
|
143
143
|
// .agenthub patterns tolerate the code points HFS+ ignores in every segment, the .git name itself is refused
|
|
144
144
|
// at any depth (creation, rename, symlink, gitfile), a submodule git dir's config and hooks under
|
|
@@ -159,14 +159,16 @@ export interface ExecResult {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
|
|
162
|
-
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal }): Promise<ExecResult> {
|
|
162
|
+
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number; env?: Record<string, string>; signal?: AbortSignal; readOnlyData?: boolean }): Promise<ExecResult> {
|
|
163
|
+
if (opts.readOnlyData && /\(allow file-write\* \(subpath /.test(opts.profile)) return Promise.resolve({ code: null, output: "error: data.readonly command profile contains a broad write grant" });
|
|
163
164
|
if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
|
|
164
165
|
if (opts.signal?.aborted) return Promise.resolve({ code: null, output: "error: command cancelled before execution" });
|
|
165
166
|
return new Promise((resolve) => {
|
|
166
167
|
// A temp dir of its own (#63): the user's is shared with every other process, and what a command reads can reach
|
|
167
168
|
// an answer shown to cloud peers. Allowed after the profile's denies, and gone when the command ends.
|
|
168
169
|
const own = realPath(mkdtempSync(join(tmpdir(), "ahub-cmd-")));
|
|
169
|
-
const sandbox = `${opts.profile}\n(allow file-read* file-write* process-exec (subpath ${q(own)}))`;
|
|
170
|
+
const sandbox = `${opts.profile}\n(allow file-read* ${opts.readOnlyData ? "" : "file-write* "}process-exec (subpath ${q(own)}))`;
|
|
171
|
+
// A path-only deny cannot contain hardlink aliases; data mode allows reads/stdout, never filesystem writes.
|
|
170
172
|
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", ...opts.env, TMPDIR: `${own}/` };
|
|
171
173
|
// Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
|
|
172
174
|
const child = spawn(SANDBOX_EXEC, ["-p", sandbox, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
package/src/local/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "nod
|
|
|
2
2
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import { realPath } from "../hub/project.ts";
|
|
4
4
|
import { isHubWritePath } from "../hub/permission-mode.ts";
|
|
5
|
+
import { matchesPolicy } from "../hub/path-policy.ts";
|
|
5
6
|
import { isDenied } from "./deny.ts";
|
|
6
7
|
import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
|
|
7
8
|
|
|
@@ -17,6 +18,7 @@ const approvalError = (picked: ToolApproval, denied: string): string | undefined
|
|
|
17
18
|
: picked === "aborted" ? "error: approval withdrawn because the turn ended; no operation was executed" : denied;
|
|
18
19
|
|
|
19
20
|
export interface ToolContext {
|
|
21
|
+
readOnly?: string[];
|
|
20
22
|
cwd: string;
|
|
21
23
|
/** Extra denylist entries from config: substrings of the project-relative path. */
|
|
22
24
|
deny: string[];
|
|
@@ -27,7 +29,7 @@ export interface ToolContext {
|
|
|
27
29
|
/** Turn cancellation signal, including the execution-budget wall cap. */
|
|
28
30
|
signal?: AbortSignal;
|
|
29
31
|
/** Only true permits an effect; expiry and abort remain distinct from a person's denial. */
|
|
30
|
-
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver) => Promise<ToolApproval>;
|
|
32
|
+
permit: (title: string, tool?: string, signal?: AbortSignal, canonicalTarget?: string, observe?: ApprovalObserver, originalPath?: string) => Promise<ToolApproval>;
|
|
31
33
|
/** Trusted process status, independent of what its stdout says. */
|
|
32
34
|
onProcessExit?: (code: number | null) => void;
|
|
33
35
|
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
@@ -44,7 +46,7 @@ const lexists = (p: string) => {
|
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
/** Absolute path inside cwd after resolving symlinks, or an Error. `write` also refuses .git and .agenthub. */
|
|
47
|
-
export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string, mode: "read" | "write"): string {
|
|
49
|
+
export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny" | "readOnly">, path: string, mode: "read" | "write"): string {
|
|
48
50
|
const root = realPath(ctx.cwd);
|
|
49
51
|
const abs = resolve(root, path);
|
|
50
52
|
// Resolve symlinks on the deepest part that exists: a link pointing outside must not pass as an inside path.
|
|
@@ -64,6 +66,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
|
|
|
64
66
|
// Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
|
|
65
67
|
// folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
|
|
66
68
|
if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
|
|
69
|
+
// Identity refusal is stable even when realPath names a protected hardlink alias.
|
|
70
|
+
if (mode === "write" && ctx.readOnly?.length && (lstatSync(real, { throwIfNoEntry: false })?.nlink ?? 1) > 1) throw new Error("hardlinked write target cannot be verified with data.readonly");
|
|
71
|
+
if (mode === "write" && matchesPolicy(root, path, ctx.readOnly ?? [])) throw new Error("target is protected by data.readonly");
|
|
67
72
|
return real;
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -142,7 +147,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
142
147
|
let file = guardPath(ctx, String(a.path), "write");
|
|
143
148
|
const approvedTarget = file;
|
|
144
149
|
const content = String(a.content ?? "");
|
|
145
|
-
const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file), "error: the user did not approve this write");
|
|
150
|
+
const refused = approvalError(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this write");
|
|
146
151
|
if (refused) return refused;
|
|
147
152
|
if (ctx.signal?.aborted) return "error: turn cancelled before write";
|
|
148
153
|
file = guardPath(ctx, String(a.path), "write");
|
|
@@ -157,7 +162,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
157
162
|
let text = readFileSync(file, "utf8");
|
|
158
163
|
let count = text.split(String(a.old)).length - 1;
|
|
159
164
|
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
160
|
-
const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file), "error: the user did not approve this edit");
|
|
165
|
+
const refused = approvalError(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file, undefined, String(a.path)), "error: the user did not approve this edit");
|
|
161
166
|
if (refused) return refused;
|
|
162
167
|
if (ctx.signal?.aborted) return "error: turn cancelled before edit";
|
|
163
168
|
// Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
|
|
@@ -176,7 +181,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
176
181
|
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
177
182
|
const refused = approvalError(await ctx.permit(`bash: ${command}`, "bash", ctx.signal), "error: the user did not approve this command");
|
|
178
183
|
if (refused) return refused;
|
|
179
|
-
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
184
|
+
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
180
185
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
181
186
|
return `${res.output}\n(exit ${res.code})`;
|
|
182
187
|
}
|
|
@@ -191,7 +196,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
|
|
|
191
196
|
if (refused) return refused;
|
|
192
197
|
}
|
|
193
198
|
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
194
|
-
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
199
|
+
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, readOnlyData: !!ctx.readOnly?.length, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
195
200
|
try { ctx.onProcessExit?.(res.code); } catch { /* optional status observer never changes a tool result */ }
|
|
196
201
|
return `${res.output}\n(exit ${res.code})`;
|
|
197
202
|
}
|
package/src/memory/brief.ts
CHANGED
|
@@ -27,33 +27,47 @@ export async function related(client: MemoryClient, project: string, query: stri
|
|
|
27
27
|
return out;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
|
|
31
|
+
export interface NativeItem { id: string; line: string; kind?: string }
|
|
32
|
+
export type NativeBrief = (task: BriefTask) => NativeItem[];
|
|
33
|
+
export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
|
-
* Task briefs: what memory already knows about a piece of work, handed over with it.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* Task briefs: what memory already knows about a piece of work, handed over with it. The hub's own store first (#258:
|
|
37
|
+
* entries one or two hops from the task's paths, symbols, issue and task, a linked fail first, superseded entries
|
|
38
|
+
* dropped), then claude-mem: index first (search), then the neighbourhood of the top hit (timeline); details stay in
|
|
39
|
+
* the store for the receiver to fetch by id. `brief_items` caps both together. A peer is never shown the same entry
|
|
40
|
+
* twice in one hub run.
|
|
34
41
|
*/
|
|
35
42
|
export class Briefs {
|
|
36
|
-
private readonly seen = new Map<PeerId, Set<
|
|
43
|
+
private readonly seen = new Map<PeerId, Set<string>>();
|
|
37
44
|
|
|
38
45
|
constructor(
|
|
39
|
-
private readonly client: MemoryClient,
|
|
46
|
+
private readonly client: MemoryClient | undefined,
|
|
40
47
|
private readonly project: string,
|
|
41
48
|
private readonly maxItems = 8,
|
|
49
|
+
private readonly native?: NativeBrief,
|
|
50
|
+
/** Counts of what a brief carried, for the research records (#258 AC11); never its text. */
|
|
51
|
+
private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
|
|
42
52
|
) {}
|
|
43
53
|
|
|
44
|
-
async forTask(peer: PeerId, task:
|
|
45
|
-
const
|
|
46
|
-
if (!found.length) return undefined;
|
|
47
|
-
const seen = this.seen.get(peer) ?? new Set<number>();
|
|
54
|
+
async forTask(peer: PeerId, task: BriefTask): Promise<string | undefined> {
|
|
55
|
+
const seen = this.seen.get(peer) ?? new Set<string>();
|
|
48
56
|
this.seen.set(peer, seen);
|
|
57
|
+
const own: NativeItem[] = [];
|
|
58
|
+
try { for (const item of this.native?.(task) ?? []) if (!seen.has(item.id) && own.length < this.maxItems) own.push(item); } catch { /* fail-open: the store is optional */ }
|
|
59
|
+
const found = this.client && own.length < this.maxItems ? await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" ")) : [];
|
|
49
60
|
const items: BriefItem[] = [];
|
|
50
61
|
for (const item of found) {
|
|
51
|
-
if (seen.has(item.id)) continue;
|
|
62
|
+
if (seen.has(`#${item.id}`)) continue;
|
|
52
63
|
items.push(item);
|
|
53
|
-
if (items.length === this.maxItems) break;
|
|
64
|
+
if (own.length + items.length === this.maxItems) break;
|
|
54
65
|
}
|
|
55
|
-
if (!items.length) return undefined;
|
|
56
|
-
for (const item of
|
|
57
|
-
|
|
66
|
+
if (!own.length && !items.length) return undefined;
|
|
67
|
+
for (const item of own) seen.add(item.id);
|
|
68
|
+
for (const item of items) seen.add(`#${item.id}`);
|
|
69
|
+
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
|
|
70
|
+
const header = own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; claude-mem by #id):" : "Memory brief (claude-mem; fetch details by id if you need them):";
|
|
71
|
+
return [header, ...own.map((i) => i.line), ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
|
|
58
72
|
}
|
|
59
73
|
}
|