@staix/agent-console 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugins.ts ADDED
@@ -0,0 +1,357 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { builtinModules } from "node:module";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join } from "node:path";
6
+ import { ACTION_ID, CONSOLE_API_MAX, CONSOLE_API_MIN, CONSOLE_FEATURES, PANEL_ID, TOOL_ID, type ConsoleContext, type ConsolePanelManifest, type ConsolePlugin, type ConsoleProvider } from "./contract.ts";
7
+
8
+ export const familyHome = (env: Record<string, string | undefined> = process.env): string => env.AGENTHUB_HOME || join(homedir(), ".agenthub");
9
+ export const pluginDir = (home: string): string => join(home, "console", "plugins");
10
+
11
+ const MANIFEST_MAX = 64 * 1024;
12
+ const OPEN_MS = 5_000;
13
+ const IMPORT_MS = 5_000;
14
+
15
+ export type PluginStatus = "verified" | "ready" | "stale" | "invalid" | "failed" | "superseded";
16
+ export interface PluginEntry {
17
+ tool: string;
18
+ file: string;
19
+ status: PluginStatus;
20
+ reason?: string;
21
+ /** From a versioned registration file, `<tool>@<version>.json` (#26). */
22
+ version?: string;
23
+ /** The verified module hash; with the tool it names this load (`ref`), so a page keeps the code it started with. */
24
+ sha256?: string;
25
+ /** `<tool>@<12 hex of sha256(module path, module hash)>`, set when ready; plugin routes accept it in place of the tool. */
26
+ ref?: string;
27
+ module?: string;
28
+ manifest?: ConsolePanelManifest;
29
+ provider?: ConsoleProvider;
30
+ }
31
+ export interface Scan { dir: string; entries: PluginEntry[]; error?: string }
32
+
33
+ const writableByOthers = (mode: number) => (mode & 0o022) !== 0;
34
+ const foreignOwner = (uid: number) => typeof process.getuid === "function" && uid !== process.getuid();
35
+
36
+ const TREE_MAX = 50_000;
37
+
38
+ /**
39
+ * Why another user could change the code a plugin runs, or undefined when nobody but you or root can.
40
+ * The boundary is the plugin's package root: the nearest directory above the resolved entry that holds a package.json.
41
+ * Every file, directory and symlink in that tree must be owned by you or root and not group- or world-writable, and
42
+ * every symlink must point inside the tree. Ancestors of the root follow the same rule, except that a sticky
43
+ * world-writable ancestor (like /tmp) passes: inside the package no other user may create or replace anything.
44
+ * node_modules and .git are skipped: installed dependencies are the installer's responsibility. Imports must stay inside
45
+ * the tree or reach node_modules (importEscapes).
46
+ */
47
+ export function replaceableBy(path: string): string | undefined {
48
+ if (typeof process.getuid !== "function") return undefined;
49
+ const me = process.getuid();
50
+ const entry = realpathSync(path);
51
+ const problem = (where: string, info: { uid: number; mode: number }, stickyOk: boolean) =>
52
+ info.uid !== me && info.uid !== 0 ? `${where} is owned by another user`
53
+ : writableByOthers(info.mode) && !(stickyOk && info.mode & 0o1000) ? `${where} is writable by others` : undefined;
54
+ const root = packageRoot(entry);
55
+ for (let dir = dirname(root), prev = ""; dir !== prev; prev = dir, dir = dirname(dir)) {
56
+ const bad = problem(dir, statSync(dir), true);
57
+ if (bad) return bad;
58
+ }
59
+ // ponytail: a full walk on every start; fine for a package of a few thousand files, cache by mtime if it grows.
60
+ const stack = [root];
61
+ const verified = new Set<string>();
62
+ let seen = 0;
63
+ while (stack.length) {
64
+ const current = stack.pop()!;
65
+ if (++seen > TREE_MAX) return `${root} has more than ${TREE_MAX} entries to verify`;
66
+ const info = lstatSync(current);
67
+ if (info.isSymbolicLink()) {
68
+ // Link modes are meaningless; only the owner can retarget a link inside a directory others cannot write.
69
+ if (info.uid !== me && info.uid !== 0) return `${current} is owned by another user`;
70
+ const target = realpathSync(current);
71
+ if (target !== root && !target.startsWith(`${root}/`)) return `${current} links outside the plugin package`;
72
+ continue;
73
+ }
74
+ const bad = problem(current, info, false);
75
+ if (bad) return bad;
76
+ if (info.isFile()) verified.add(current);
77
+ if (info.isDirectory()) for (const name of readdirSync(current)) if (name !== ".git" && name !== "node_modules") stack.push(join(current, name));
78
+ }
79
+ return importEscapes(entry, root, verified);
80
+ }
81
+
82
+ /** The nearest directory above a resolved file that holds a package.json, else the file's own directory. */
83
+ export function packageRoot(file: string): string {
84
+ let root = dirname(file);
85
+ while (!existsSync(join(root, "package.json")) && dirname(root) !== root) root = dirname(root);
86
+ return existsSync(join(root, "package.json")) ? root : dirname(file);
87
+ }
88
+
89
+ const CODE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
90
+ const LOADER = (file: string) => (/\.(ts|mts|cts)$/.test(file) ? "ts" : /\.tsx$/.test(file) ? "tsx" : /\.jsx$/.test(file) ? "jsx" : "js");
91
+
92
+ /**
93
+ * Follows the static import graph from the entry: every file it reaches must be one the package walk verified, or in
94
+ * a node_modules directory of the root or one of its ancestors, or Bun's install cache (installed dependencies, the
95
+ * installer's responsibility).
96
+ * ponytail: dynamic import() with a computed specifier cannot be followed; literal ones are.
97
+ */
98
+ function importEscapes(entry: string, root: string, verified: Set<string>): string | undefined {
99
+ const dependencyDirs: string[] = [];
100
+ for (let dir = root, prev = ""; dir !== prev; prev = dir, dir = dirname(dir)) dependencyDirs.push(`${join(dir, "node_modules")}/`);
101
+ // Bun's install cache serves dependencies when a package has no node_modules (auto-install).
102
+ dependencyDirs.push(`${process.env.BUN_INSTALL_CACHE_DIR || join(process.env.BUN_INSTALL || join(homedir(), ".bun"), "install", "cache")}/`);
103
+ // Package files must be ones the walk checked; skipped directories (.git, node_modules) never qualify.
104
+ if (!verified.has(entry)) return `${entry} is not a verified package file`;
105
+ const seen = new Set<string>();
106
+ const queue = [entry];
107
+ while (queue.length) {
108
+ const file = queue.pop()!;
109
+ if (seen.has(file)) continue;
110
+ seen.add(file);
111
+ if (seen.size > TREE_MAX) return `${root} imports more than ${TREE_MAX} files`;
112
+ if (!CODE.test(file)) continue;
113
+ // A leading #! line is valid in a Bun module (CLI entry files have one), but Bun's import scanner throws on it.
114
+ // Stop at any JS line terminator (LF, CR, LS, PS): Bun runs what follows, so the scanner must see it too.
115
+ const source = readFileSync(file, "utf8").replace(/^#![^\n\r\u2028\u2029]*/, "");
116
+ let imports: { path: string }[];
117
+ try { imports = new Bun.Transpiler({ loader: LOADER(file) }).scanImports(source); }
118
+ catch { return `${file} could not be scanned for imports`; }
119
+ for (const { path } of imports) {
120
+ if (/^(node|bun):/.test(path) || path === "bun" || builtinModules.includes(path)) continue;
121
+ // Any other scheme (data:, file:, http:) would load code that is not a verified file.
122
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return `${file} imports ${path.slice(0, 40)}, which is not a package file`;
123
+ let resolved: string;
124
+ try { resolved = Bun.resolveSync(path, dirname(file)); } catch { continue; } // import() itself will fail and the plugin is marked failed
125
+ if (!isAbsolute(resolved)) return `${file} imports ${path.slice(0, 40)}, which is not a package file`;
126
+ if (dependencyDirs.some((d) => resolved.startsWith(d))) continue;
127
+ const real = realpathSync(resolved);
128
+ if (!verified.has(real)) return `${file} imports ${path}, which is outside the plugin package`;
129
+ queue.push(real);
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ const VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:[-+][0-9A-Za-z.+-]{1,40})?$/;
136
+ const FILE = /^([a-z][a-z0-9-]{1,15})(?:@([^/]{1,64}))?\.json$/;
137
+
138
+ /**
139
+ * Why an api declaration does not overlap the console's range, or undefined when it does (#26). `api` alone means
140
+ * apiMin = apiMax = api; a range lets one plugin version load in consoles that speak different apis.
141
+ */
142
+ export function apiRangeProblem(m: { api?: unknown; apiMin?: unknown; apiMax?: unknown }, range = { min: CONSOLE_API_MIN, max: CONSOLE_API_MAX }): string | undefined {
143
+ const int = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v >= 1;
144
+ for (const key of ["api", "apiMin", "apiMax"] as const) if (m[key] !== undefined && !int(m[key])) return `${key} must be a positive integer`;
145
+ const min = (m.apiMin ?? m.api) as number | undefined;
146
+ const max = (m.apiMax ?? m.api ?? m.apiMin) as number | undefined;
147
+ if (min === undefined || max === undefined) return "api is required";
148
+ if (min > max) return `apiMin ${min} is above apiMax ${max}`;
149
+ if (max < range.min || min > range.max) return `unsupported api ${min === max ? min : `${min}-${max}`}; this console speaks api ${range.min === range.max ? range.min : `${range.min}-${range.max}`}`;
150
+ return undefined;
151
+ }
152
+
153
+ /** Reads every manifest and verifies the module hash. Nothing is imported here. */
154
+ export function scanPlugins(home: string): Scan {
155
+ const dir = pluginDir(home);
156
+ if (!existsSync(dir)) return { dir, entries: [] };
157
+ const info = lstatSync(dir);
158
+ if (!info.isDirectory()) return { dir, entries: [], error: "plugin path is not a directory" };
159
+ // Manifests choose code that runs in this process, so only the owner may write them.
160
+ if (writableByOthers(info.mode) || foreignOwner(info.uid)) return { dir, entries: [], error: `plugin directory must be owned by you and not group- or world-writable; run chmod 700 ${dir}` };
161
+ let names: string[];
162
+ try { names = readdirSync(dir); } catch { return { dir, entries: [], error: "plugin directory cannot be read" }; }
163
+ const entries = names.filter((name) => name.endsWith(".json")).sort().map((name) => {
164
+ const file = join(dir, name);
165
+ // One unreadable manifest or module marks that plugin invalid; the others still load.
166
+ try { return readEntry(file); } catch { return { tool: basename(name, ".json"), file, status: "invalid" as const, reason: "manifest or module cannot be read" }; }
167
+ });
168
+ return { dir, entries };
169
+ }
170
+
171
+ function readEntry(file: string): PluginEntry {
172
+ const named = FILE.exec(basename(file));
173
+ const tool = named?.[1] ?? basename(file, ".json");
174
+ const version = named?.[2];
175
+ const bad = (reason: string): PluginEntry => ({ tool, file, status: "invalid", reason });
176
+ if (!named || !TOOL_ID.test(tool)) return bad("file name is not <tool>.json or <tool>@<version>.json");
177
+ if (version !== undefined && !VERSION.test(version)) return bad("the version in the file name is not a semantic version");
178
+ const info = lstatSync(file);
179
+ if (!info.isFile()) return bad("manifest is not a regular file");
180
+ if (writableByOthers(info.mode) || foreignOwner(info.uid)) return bad("manifest is writable by others");
181
+ if (info.size > MANIFEST_MAX) return bad("manifest is larger than 64 KiB");
182
+ let value: unknown;
183
+ try { value = JSON.parse(readFileSync(file, "utf8")); } catch { return bad("manifest is not valid JSON"); }
184
+ if (!value || typeof value !== "object" || Array.isArray(value)) return bad("manifest must be a JSON object");
185
+ const m = value as Record<string, unknown>;
186
+ const extra = Object.keys(m).filter((k) => !["api", "apiMin", "apiMax", "tool", "module", "sha256"].includes(k));
187
+ if (extra.length) return bad(`unknown manifest key: ${extra[0]}`);
188
+ const api = apiRangeProblem(m);
189
+ if (api) return bad(api);
190
+ if (m.tool !== tool) return bad("manifest tool does not match its file name");
191
+ if (typeof m.module !== "string" || !isAbsolute(m.module) || !/\.(ts|js|mjs)$/.test(m.module)) return bad("module must be an absolute .ts, .js or .mjs path");
192
+ if (typeof m.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(m.sha256)) return bad("sha256 must be 64 lowercase hex characters");
193
+ const module = m.module;
194
+ if (!existsSync(module) || !statSync(module).isFile()) return { tool, file, module, status: "stale", reason: `module is missing; re-run \`${tool} setup\`` };
195
+ const unsafe = replaceableBy(module);
196
+ if (unsafe) return { tool, file, module, status: "invalid", reason: unsafe };
197
+ // Hash and import the resolved path that was checked, so a symlink on the way cannot be swapped afterwards.
198
+ const real = realpathSync(module);
199
+ const actual = createHash("sha256").update(readFileSync(real)).digest("hex");
200
+ if (actual !== m.sha256) return { tool, file, version, module: real, status: "stale", reason: `module changed since setup; re-run \`${tool} setup\`` };
201
+ return { tool, file, version, module: real, sha256: actual, status: "verified" };
202
+ }
203
+
204
+ /** Problems with a plugin manifest, empty when valid. Shared with the conformance suite. */
205
+ export function manifestProblems(manifest: unknown, tool?: string): string[] {
206
+ if (!manifest || typeof manifest !== "object") return ["manifest is missing"];
207
+ const m = manifest as Partial<ConsolePanelManifest>;
208
+ const out: string[] = [];
209
+ const api = apiRangeProblem(m);
210
+ if (api) out.push(`manifest: ${api}`);
211
+ if (typeof m.tool !== "string" || !TOOL_ID.test(m.tool)) out.push("manifest.tool must match ^[a-z][a-z0-9-]{1,15}$");
212
+ else if (tool && m.tool !== tool) out.push(`manifest.tool is ${m.tool}, expected ${tool}`);
213
+ if (typeof m.version !== "string" || !m.version) out.push("manifest.version is required");
214
+ if (typeof m.title !== "string" || !m.title) out.push("manifest.title is required");
215
+ if (!Array.isArray(m.panels) || m.panels.length === 0) out.push("manifest.panels needs at least one panel");
216
+ else {
217
+ const ids = new Set<string>();
218
+ for (const p of m.panels) {
219
+ if (!p || typeof p.id !== "string" || !PANEL_ID.test(p.id) || ["inbox", "search"].includes(p.id)) out.push(`panel id ${String(p?.id)} is invalid or reserved`);
220
+ else if (ids.has(p.id)) out.push(`panel id ${p.id} is duplicated`);
221
+ else ids.add(p.id);
222
+ if (!p || typeof p.title !== "string" || !p.title) out.push("every panel needs a title");
223
+ }
224
+ }
225
+ if (m.pollMs !== undefined && (typeof m.pollMs !== "number" || !Number.isFinite(m.pollMs) || m.pollMs <= 0)) out.push("manifest.pollMs must be a positive number");
226
+ const actions = m.capabilities?.actions;
227
+ if (actions !== undefined && (!Array.isArray(actions) || actions.some((a) => typeof a !== "string"))) out.push("capabilities.actions must be strings");
228
+ if (m.capabilities?.space !== undefined && typeof m.capabilities.space !== "boolean") out.push("capabilities.space must be a boolean");
229
+ if (m.commands !== undefined) {
230
+ if (!Array.isArray(m.commands)) out.push("manifest.commands must be an array");
231
+ else {
232
+ const ids = new Set<string>();
233
+ for (const c of m.commands) {
234
+ const label = `command ${String(c?.id)}`;
235
+ if (!c || typeof c.id !== "string" || !ACTION_ID.test(c.id)) { out.push(`${label}: id must match ${ACTION_ID.source}`); continue; }
236
+ if (ids.has(c.id)) out.push(`${label} is duplicated`);
237
+ ids.add(c.id);
238
+ if (typeof c.title !== "string" || !c.title) out.push(`${label} needs a title`);
239
+ if (c.hint !== undefined && typeof c.hint !== "string") out.push(`${label}: hint must be a string`);
240
+ if (typeof c.action !== "string" || !ACTION_ID.test(c.action)) out.push(`${label}: action must match ${ACTION_ID.source}`);
241
+ else if (Array.isArray(actions) && !actions.includes(c.action)) out.push(`${label}: action ${c.action} is not in capabilities.actions`);
242
+ if (c.input !== undefined && (!c.input || typeof c.input !== "object" || Array.isArray(c.input))) out.push(`${label}: input must be an object`);
243
+ else if (c.input && ["action", "confirmed", "fingerprint", "confirmToken"].some((k) => k in c.input!)) out.push(`${label}: input may not set action, confirmed, fingerprint or confirmToken`);
244
+ }
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+
250
+ export function providerProblems(provider: unknown): string[] {
251
+ const p = provider as Partial<ConsoleProvider> | undefined;
252
+ if (!p || typeof p !== "object") return ["open() must resolve to a provider object"];
253
+ const out: string[] = [];
254
+ if (!p.assets || typeof p.assets.js !== "string") out.push("provider.assets.js must be a string");
255
+ if (p.assets?.css !== undefined && typeof p.assets.css !== "string") out.push("provider.assets.css must be a string");
256
+ if (typeof p.snapshot !== "function") out.push("provider.snapshot must be a function");
257
+ if (typeof p.action !== "function") out.push("provider.action must be a function");
258
+ if (p.asset !== undefined && typeof p.asset !== "function") out.push("provider.asset must be a function when present");
259
+ return out;
260
+ }
261
+
262
+ /** Plugin asset types the console serves; SVG is only ever an attachment so its scripts never run in the console origin. */
263
+ export const ASSET_TYPES: ReadonlySet<string> = new Set(["application/pdf", "image/png", "image/jpeg", "image/svg+xml", "text/plain", "text/csv"]);
264
+
265
+ /** Problems in one provider.asset() answer that is not null. */
266
+ export function assetResultProblems(result: unknown): string[] {
267
+ const r = result as { bytes?: unknown; mime?: unknown; name?: unknown } | null;
268
+ if (!r || typeof r !== "object") return ["asset must answer {bytes, mime, name} or null"];
269
+ const out: string[] = [];
270
+ if (!(r.bytes instanceof Uint8Array)) out.push("asset.bytes must be a Uint8Array");
271
+ if (typeof r.mime !== "string" || !r.mime) out.push("asset.mime must be a non-empty string");
272
+ if (typeof r.name !== "string" || !r.name) out.push("asset.name must be a non-empty string");
273
+ return out;
274
+ }
275
+
276
+ export class TimeoutError extends Error {}
277
+
278
+ export function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
279
+ let timer: ReturnType<typeof setTimeout> | undefined;
280
+ return Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new TimeoutError(`${what} timed out after ${ms / 1000} s`)), ms); })])
281
+ .finally(() => clearTimeout(timer));
282
+ }
283
+
284
+ /**
285
+ * Module paths this process has imported, with the hash verified at that import. Bun keeps one module per path for
286
+ * the life of the process, whether or not open() then succeeded, so this is what a later load at that path would get.
287
+ */
288
+ const imported = new Map<string, string>();
289
+ /** Package roots this process has imported from: their files, entries or dependencies, are cached by Bun as first read. */
290
+ const importedRoots = new Set<string>();
291
+
292
+ /** Newest first: versioned registrations by semantic version, then the unversioned slot. */
293
+ const newestFirst = (a: PluginEntry, b: PluginEntry) =>
294
+ a.version === undefined ? (b.version === undefined ? 0 : 1) : b.version === undefined ? -1 : Bun.semver.order(b.version, a.version);
295
+
296
+ /**
297
+ * Imports verified modules and opens their providers. Per tool the newest verified registration loads; if it fails,
298
+ * the next one is tried, and the rest are `superseded`. A failure marks that plugin failed; the others still load.
299
+ * `previous` holds earlier loads: a registration with the same file and hash reuses its provider instead of importing
300
+ * again, so a re-scan (#26) only imports what is new.
301
+ */
302
+ export async function loadPlugins(scan: Scan, ctx: ConsoleContext, previous: PluginEntry[] = []): Promise<PluginEntry[]> {
303
+ const out: PluginEntry[] = [];
304
+ // Roots imported before this pass; a first pass (console start) may load several entries from one package.
305
+ const before = new Set(importedRoots);
306
+ const candidates = new Map<string, PluginEntry[]>();
307
+ for (const entry of scan.entries) {
308
+ if (entry.status !== "verified" || !entry.module) { out.push(entry); continue; }
309
+ candidates.set(entry.tool, [...(candidates.get(entry.tool) ?? []), entry]);
310
+ }
311
+ for (const list of candidates.values()) {
312
+ let chosen: PluginEntry | undefined;
313
+ for (const entry of list.sort(newestFirst)) {
314
+ if (chosen) { out.push({ ...entry, status: "superseded", reason: `${chosen.version ?? "the unversioned registration"} is newer` }); continue; }
315
+ // Reuse only the same tool's load: a registration copied with another tool's module must fail by name, not hide.
316
+ const same = previous.find((p) => p.status === "ready" && p.tool === entry.tool && p.module === entry.module && p.sha256 === entry.sha256);
317
+ // Bun caches a module by path for the life of the process: changed code at a path already loaded would come back
318
+ // as the old module (and its imports). Only a new path loads without a restart, as a versioned install provides.
319
+ // The same holds for any file of a package already imported from (an entry's dependency, say): only bytes this
320
+ // process verified at that exact path may load again.
321
+ const inPlace = !same && before.has(packageRoot(entry.module!)) && imported.get(entry.module!) !== entry.sha256;
322
+ const loaded = same ?? (inPlace
323
+ ? { ...entry, status: "stale" as const, reason: "this module is in a package the console already loaded, so the runtime would serve its cached code; restart acon to use it, or install the new version at its own path" }
324
+ : await loadOne(entry, ctx));
325
+ out.push(loaded);
326
+ if (loaded.status === "ready") chosen = loaded;
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+
332
+ async function loadOne(entry: PluginEntry, ctx: ConsoleContext): Promise<PluginEntry> {
333
+ try {
334
+ // ponytail: the verified bytes are re-read by import(); only you or root can swap them (replaceableBy), and the module's own imports are not hashed.
335
+ // A path is imported once per process (see loadPlugins), so what import() returns is what was verified at first load.
336
+ if (!imported.has(entry.module!)) imported.set(entry.module!, entry.sha256!);
337
+ importedRoots.add(packageRoot(entry.module!));
338
+ const mod = await withTimeout(import(entry.module!), IMPORT_MS, "import") as { default?: ConsolePlugin; plugin?: ConsolePlugin };
339
+ const plugin = mod.default ?? mod.plugin;
340
+ if (!plugin || typeof plugin.open !== "function") return { ...entry, status: "failed", reason: "module exports no console plugin" };
341
+ const problems = manifestProblems(plugin.manifest, entry.tool);
342
+ if (problems.length) return { ...entry, status: "failed", reason: problems[0] };
343
+ const provider = await withTimeout(plugin.open({ ...ctx, features: [...CONSOLE_FEATURES] }), OPEN_MS, "open()");
344
+ const pp = providerProblems(provider);
345
+ if (pp.length) return { ...entry, status: "failed", reason: pp[0] };
346
+ // The manifest goes to the browser as JSON; a value that cannot (BigInt, cycles) fails this plugin only.
347
+ let manifest: ConsolePanelManifest;
348
+ try { manifest = JSON.parse(JSON.stringify(plugin.manifest)) as ConsolePanelManifest; }
349
+ catch { return { ...entry, status: "failed", reason: "manifest is not JSON-serializable" }; }
350
+ // A load is its module path and bytes: two packages with the same entry file but different imports get distinct refs.
351
+ const ref = `${entry.tool}@${createHash("sha256").update(`${entry.module}\n${entry.sha256}`).digest("hex").slice(0, 12)}`;
352
+ return { ...entry, status: "ready", manifest, provider, ref };
353
+ } catch (error) {
354
+ ctx.log(`${entry.tool}: load failed: ${error instanceof Error ? error.message : String(error)}`);
355
+ return { ...entry, status: "failed", reason: "plugin failed to load; see the terminal" };
356
+ }
357
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
3
+
4
+ /**
5
+ * H1 (`acon serve`, #20): resident console behind Cloudflare Access or a bearer token.
6
+ * Only builtins: WebCrypto verifies the Access JWT, node:crypto compares the bearer token.
7
+ */
8
+
9
+ export type Authenticate = (req: Request) => Promise<string | null>;
10
+ export interface ServeOptions {
11
+ hostname: string;
12
+ port: number;
13
+ /** Exact browser origins allowed for POST; their hosts (plus the bind address) are the allowed Host values. */
14
+ origins: string[];
15
+ hosts: string[];
16
+ /** Returns the verified principal (the session key confirm tokens bind to) or null for 401. */
17
+ authenticate: Authenticate;
18
+ mode: "access" | "bearer" | "loopback";
19
+ }
20
+
21
+ const SKEW_S = 60;
22
+ const REFRESH_MS = 30_000;
23
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
24
+ export const isLoopback = (host: string) => LOOPBACK.has(host) || /^127\.\d+\.\d+\.\d+$/.test(host);
25
+
26
+ const b64url = (part: string): Uint8Array<ArrayBuffer> | null => {
27
+ if (!/^[A-Za-z0-9_-]*$/.test(part)) return null;
28
+ return new Uint8Array(Buffer.from(part, "base64url"));
29
+ };
30
+ const jsonPart = (part: string): Record<string, unknown> | null => {
31
+ const bytes = b64url(part);
32
+ if (!bytes) return null;
33
+ try {
34
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
35
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
36
+ } catch { return null; }
37
+ };
38
+
39
+ /** `team` is the Access team domain, e.g. `<team>.cloudflareaccess.com` (with or without https://). */
40
+ export function normalizeTeam(team: string): string | null {
41
+ const host = team.trim().replace(/^https:\/\//, "").replace(/\/+$/, "");
42
+ return /^[A-Za-z0-9.-]+$/.test(host) && host.includes(".") ? host.toLowerCase() : null;
43
+ }
44
+
45
+ export interface AccessVerifierOptions { team: string; aud: string; certsUrl?: string; now?: () => number; fetchImpl?: typeof fetch }
46
+
47
+ /** RS256 Cloudflare Access JWT verification against the team JWKS, cached and refreshed on an unknown kid. */
48
+ export function accessVerifier(o: AccessVerifierOptions): (jwt: string) => Promise<{ sub: string } | string> {
49
+ const team = normalizeTeam(o.team);
50
+ if (!team) throw new Error("ACON_ACCESS_TEAM must be a team domain such as <team>.cloudflareaccess.com");
51
+ const issuer = `https://${team}`;
52
+ const certsUrl = o.certsUrl ?? `${issuer}/cdn-cgi/access/certs`;
53
+ const now = o.now ?? Date.now;
54
+ const fetchImpl = o.fetchImpl ?? fetch;
55
+ let keys = new Map<string, CryptoKey>();
56
+ let fetchedAt = -Infinity;
57
+ let inflight: Promise<void> | null = null;
58
+
59
+ const refresh = () => inflight ??= (async () => {
60
+ try {
61
+ fetchedAt = now();
62
+ const res = await fetchImpl(certsUrl, { signal: AbortSignal.timeout(10_000), redirect: "error" });
63
+ if (!res.ok) throw new Error(`JWKS answered ${res.status}`);
64
+ const body = await res.json() as { keys?: unknown };
65
+ const next = new Map<string, CryptoKey>();
66
+ for (const jwk of Array.isArray(body.keys) ? body.keys as Record<string, unknown>[] : []) {
67
+ if (!jwk || jwk.kty !== "RSA" || typeof jwk.kid !== "string" || typeof jwk.n !== "string" || typeof jwk.e !== "string") continue;
68
+ if (jwk.use !== undefined && jwk.use !== "sig") continue;
69
+ try {
70
+ next.set(jwk.kid, await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]));
71
+ } catch { /* skip a malformed key */ }
72
+ }
73
+ if (next.size) keys = next;
74
+ } finally { inflight = null; }
75
+ })();
76
+
77
+ const keyFor = async (kid: string): Promise<CryptoKey | undefined> => {
78
+ // A refresh already in flight (cold cache, rotation) is awaited by every caller, so parallel first requests
79
+ // do not see "unknown key id"; the 30 s throttle only limits starting a new one.
80
+ if (!keys.has(kid) && inflight) { try { await inflight; } catch { /* fall through to the cache */ } }
81
+ if (!keys.has(kid) && now() - fetchedAt >= REFRESH_MS) {
82
+ try { await refresh(); } catch { /* keep the cached keys; the token fails below */ }
83
+ }
84
+ return keys.get(kid);
85
+ };
86
+
87
+ return async (jwt: string) => {
88
+ const parts = jwt.split(".");
89
+ if (parts.length !== 3 || jwt.length > 16 * 1024) return "malformed token";
90
+ const [h, p, s] = parts as [string, string, string];
91
+ const header = jsonPart(h);
92
+ const payload = jsonPart(p);
93
+ const signature = b64url(s);
94
+ if (!header || !payload || !signature) return "malformed token";
95
+ if (header.alg !== "RS256") return "unsupported algorithm";
96
+ if (typeof header.kid !== "string") return "missing key id";
97
+ const key = await keyFor(header.kid);
98
+ if (!key) return "unknown key id";
99
+ const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(`${h}.${p}`));
100
+ if (!ok) return "bad signature";
101
+ const aud = payload.aud;
102
+ if (!(aud === o.aud || (Array.isArray(aud) && aud.includes(o.aud)))) return "wrong audience";
103
+ if (payload.iss !== issuer) return "wrong issuer";
104
+ const t = now() / 1000;
105
+ if (typeof payload.exp !== "number" || t > payload.exp + SKEW_S) return "expired";
106
+ if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || t < payload.nbf - SKEW_S)) return "not yet valid";
107
+ const sub = typeof payload.sub === "string" && payload.sub ? payload.sub : typeof payload.email === "string" && payload.email ? payload.email : null;
108
+ if (!sub) return "no subject";
109
+ return { sub };
110
+ };
111
+ }
112
+
113
+ /** Reads a token file that only its owner may read; returns a constant-time checker or a refusal. */
114
+ export function bearerVerifier(file: string): ((header: string | null) => boolean) | string {
115
+ let token: string;
116
+ try {
117
+ const stat = statSync(file);
118
+ if (!stat.isFile()) return `ACON_TOKEN_FILE ${file} is not a regular file`;
119
+ if (stat.mode & 0o077) return `ACON_TOKEN_FILE ${file} must not be readable by group or others (chmod 600)`;
120
+ token = readFileSync(file, "utf8").trim();
121
+ } catch (error) {
122
+ return `ACON_TOKEN_FILE ${file} could not be read (${(error as NodeJS.ErrnoException).code ?? "error"})`;
123
+ }
124
+ if (token.length < 32 || /\s/.test(token)) return "ACON_TOKEN_FILE must hold one token of at least 32 characters";
125
+ // Hashing both sides first gives equal-length inputs, so timingSafeEqual never leaks the token length.
126
+ const want = createHash("sha256").update(token).digest();
127
+ return (header) => {
128
+ const given = header?.startsWith("Bearer ") ? header.slice(7).trim() : "";
129
+ const ok = timingSafeEqual(createHash("sha256").update(given).digest(), want);
130
+ return ok && given.length > 0;
131
+ };
132
+ }
133
+
134
+ export interface ServeArgs { host?: string; port?: string; origins: string[] }
135
+
136
+ /** Builds the serve options from flags and the environment; a string is a refusal to print. */
137
+ export function serveConfig(args: ServeArgs, env: Record<string, string | undefined>, certsUrl?: string): ServeOptions | string {
138
+ const hostname = args.host ?? "127.0.0.1";
139
+ if (!/^[A-Za-z0-9.:[\]-]+$/.test(hostname)) return `--host ${hostname} is not a host name or address`;
140
+ const port = Number(args.port ?? (env.ACON_PORT || "8787"));
141
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) return "--port (or ACON_PORT) must be 1-65535";
142
+ const origins: string[] = [];
143
+ for (const raw of [...args.origins, ...(env.ACON_ORIGINS ?? "").split(",").map((v) => v.trim()).filter(Boolean)]) {
144
+ let url: URL;
145
+ try { url = new URL(raw); } catch { return `--origin ${raw} is not a URL`; }
146
+ if (url.origin !== raw.replace(/\/$/, "") || (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname)))) return `--origin ${raw} must be an exact https origin (http only for loopback), without a path`;
147
+ if (!origins.includes(url.origin)) origins.push(url.origin);
148
+ }
149
+ const loopback = isLoopback(hostname);
150
+ const bind = `${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}:${port}`;
151
+ if (loopback) origins.push(`http://${bind}`);
152
+ else if (!origins.length) return `acon serve --host ${hostname} needs at least one --origin (or ACON_ORIGINS): the exact browser origin, such as https://console.example.com`;
153
+ const hosts = [...new Set([...origins.map((o) => new URL(o).host), bind, `127.0.0.1:${port}`, `localhost:${port}`])];
154
+
155
+ const team = env.ACON_ACCESS_TEAM, aud = env.ACON_ACCESS_AUD, tokenFile = env.ACON_TOKEN_FILE;
156
+ if ((team && !aud) || (!team && aud)) return "set both ACON_ACCESS_TEAM and ACON_ACCESS_AUD for Cloudflare Access";
157
+ if (team && aud && tokenFile) return "set either Cloudflare Access (ACON_ACCESS_TEAM, ACON_ACCESS_AUD) or ACON_TOKEN_FILE, not both";
158
+ if (team && aud) {
159
+ let verify: ReturnType<typeof accessVerifier>;
160
+ try { verify = accessVerifier({ team, aud, ...(certsUrl ? { certsUrl } : {}) }); } catch (error) { return (error as Error).message; }
161
+ return { hostname, port, origins, hosts, mode: "access", authenticate: async (req) => {
162
+ const jwt = req.headers.get("cf-access-jwt-assertion");
163
+ if (!jwt) return null;
164
+ const result = await verify(jwt);
165
+ return typeof result === "string" ? null : `access:${result.sub}`;
166
+ } };
167
+ }
168
+ if (tokenFile) {
169
+ const check = bearerVerifier(tokenFile);
170
+ if (typeof check === "string") return check;
171
+ return { hostname, port, origins, hosts, mode: "bearer", authenticate: async (req) => check(req.headers.get("authorization")) ? "bearer" : null };
172
+ }
173
+ if (!loopback) return `refusing to serve on ${hostname} without authentication: set ACON_ACCESS_TEAM and ACON_ACCESS_AUD (Cloudflare Access) or ACON_TOKEN_FILE (bearer token)`;
174
+ return { hostname, port, origins, hosts, mode: "loopback", authenticate: async () => "loopback" };
175
+ }