diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5099e5f..57dc52f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,7 +3,10 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { ".": "./src/index.ts" }, + "exports": { + ".": "./src/index.ts", + "./testing": "./src/testing.ts" + }, "scripts": { "build": "echo 'build: not yet implemented'", "typecheck": "tsc --noEmit", diff --git a/packages/sdk/src/atomic.ts b/packages/sdk/src/atomic.ts new file mode 100644 index 0000000..ad76bf6 --- /dev/null +++ b/packages/sdk/src/atomic.ts @@ -0,0 +1,99 @@ +/** + * Atomic-write helpers — store-layout.md §7.1: every write under + * `~/.agents/` is staged to a sibling temp path, then renamed. Readers + * see the complete old file or the complete new one, never a partial. + */ + +import { FsPort } from "./ports.js"; +import { dirname, join } from "./path.js"; + +const nonce = (): string => + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); + +/** Write a file atomically: sibling temp + rename. */ +export const atomicWriteFile = async ( + fs: FsPort, + path: string, + data: Uint8Array, +): Promise => { + const tmp = join(dirname(path), `.${path.slice(path.lastIndexOf("/") + 1)}.tmp-${nonce()}`); + try { + await fs.writeFile(tmp, data); + await fs.rename(tmp, path); + } catch (e) { + await fs.remove(tmp, { recursive: true }).catch(() => undefined); + throw e; + } +}; + +/** + * Move a staged directory into place. `dest` must not exist — callers + * use `swapDirectory` for the update path. + */ +export const renameIntoPlace = async ( + fs: FsPort, + staged: string, + dest: string, +): Promise => { + await fs.rename(staged, dest); +}; + +/** + * Atomic-ish package swap (trust.md §3.3): the old tree is renamed to a + * trash sibling first, the new tree renamed in, then the trash removed. + * Same-directory renames keep each step atomic; the brief gap between + * them is why callers hold `.lock`. + */ +export const swapDirectory = async ( + fs: FsPort, + staged: string, + dest: string, +): Promise => { + const trash = `${dest}.old-${nonce()}`; + const st = await fs.stat(dest); + if (st !== null) await fs.rename(dest, trash); + try { + await fs.rename(staged, dest); + } catch (e) { + if (st !== null) await fs.rename(trash, dest).catch(() => undefined); + throw e; + } + if (st !== null) await fs.remove(trash, { recursive: true }); +}; + +/** Copy a directory tree recursively through the port (local sources). */ +export const copyTree = async ( + fs: FsPort, + from: string, + to: string, +): Promise => { + const st = await fs.stat(from); + if (st === null) return; + if (st.type === "directory") { + await fs.mkdir(to); + for (const entry of await fs.readDir(from)) { + await copyTree(fs, join(from, entry.name), join(to, entry.name)); + } + return; + } + if (st.type === "file") { + await fs.writeFile(to, await fs.readFile(from)); + return; + } + if (st.type === "symlink") { + const target = await fs.readlink(from); + if (fs.symlink !== undefined) { + await fs.symlink(target, to); + return; + } + // Ports without a symlink primitive dereference, but only when the + // target stays inside the copied tree — containment (§6.3) still holds. + const resolved = target.startsWith("/") + ? target + : join(dirname(from), target); + const data = await fs.readFile(resolved).catch(() => null); + if (data === null) + throw new Error(`cannot copy dangling/unreadable symlink ${from} -> ${target}`); + await fs.writeFile(to, data); + } +}; diff --git a/packages/sdk/src/audit.ts b/packages/sdk/src/audit.ts new file mode 100644 index 0000000..a313461 --- /dev/null +++ b/packages/sdk/src/audit.ts @@ -0,0 +1,45 @@ +/** + * Audit log — `harness/audit.log`, append-only JSONL (trust.md §6). + * One object per line; writers must already hold `harness/.lock`. + * Records never carry secrets (§6.3) — `details` is call-site data. + */ + +import { FsPort } from "./ports.js"; +import type { AuditRecord } from "./types.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export const appendAudit = async ( + fs: FsPort, + auditPath: string, + record: AuditRecord, +): Promise => { + const line = `${JSON.stringify(record)}\n`; + await fs.appendFile(auditPath, encoder.encode(line)); +}; + +/** Read the log, tolerating partial lines from crashed writers (§6.1). */ +export const readAudit = async ( + fs: FsPort, + auditPath: string, +): Promise<{ records: AuditRecord[]; skipped: number }> => { + let text: string; + try { + text = decoder.decode(await fs.readFile(auditPath)); + } catch { + return { records: [], skipped: 0 }; + } + const records: AuditRecord[] = []; + let skipped = 0; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "") continue; + try { + records.push(JSON.parse(trimmed) as AuditRecord); + } catch { + skipped++; + } + } + return { records, skipped }; +}; diff --git a/packages/sdk/src/bridge.test.ts b/packages/sdk/src/bridge.test.ts new file mode 100644 index 0000000..2561ccc --- /dev/null +++ b/packages/sdk/src/bridge.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { createBridgeSession, handleBridgeRequest, type JsonRpcRequest } from "./bridge.js"; +import { createStore, type Store } from "./store.js"; +import { createTestPorts } from "./testing.js"; + +const ROOT = "/agents"; + +const makeStore = async (): Promise => { + const ports = createTestPorts(); + ports.fs.putFile( + "/src/plug/plugin.json", + JSON.stringify({ + $schema: "x", + name: "plug", + version: "1.0.0", + extensions: { "dev.anyharness": { namespaceVersion: 1, capabilities: ["storage.fs"] } }, + }), + ); + ports.fs.putFile("/src/plug/skills/helper/SKILL.md", "---\nname: helper\ndescription: d\n---\nBody."); + ports.fs.putFile("/src/plug/dev.anyharness/commands/lint.md", "---\ndescription: lint\n---\nLint it."); + const store = createStore(ROOT, ports); + await store.install("/src/plug"); + return store; +}; + +const req = (method: string, params?: Record, id: string | number = "r1"): JsonRpcRequest => ({ + jsonrpc: "2.0", + id, + method, + params, +}); + +const negotiate = async (store: Store, session?: ReturnType) => + handleBridgeRequest(store, req("capabilities.negotiate", { + protocol: { supported: ["0.1", "0.9"] }, + client: { name: "test-harness", version: "1.0" }, + capabilities: { + kinds: ["skill", "command", "hook", "mcp", "plugin"], + hookEvents: ["tool.before"], + slots: { storage: "fs", secrets: "host", exec: true, skills: "read-write", mcp: "external" }, + }, + }, "neg"), session); + +describe("handleBridgeRequest", () => { + it("handshake gate: non-negotiate first → -32002", async () => { + const store = await makeStore(); + const res = await handleBridgeRequest(store, req("extensions.list")); + expect(res.error?.code).toBe(-32002); + }); + + it("negotiate picks common protocol + returns granted caps; second negotiate → -32602", async () => { + const store = await makeStore(); + const res = await negotiate(store); + expect(res.error).toBeUndefined(); + const result = res.result as Record; + expect((result["protocol"] as { version: string }).version).toBe("0.1"); + const caps = result["capabilities"] as { slots: Record }; + expect(caps.slots["storage"]).toBe("fs"); + expect(caps.slots["mcp"]).toBe("external"); + + const again = await handleBridgeRequest(store, req("capabilities.negotiate", { + protocol: { supported: ["0.1"] }, + client: { name: "x", version: "1" }, + capabilities: {}, + })); + expect(again.error?.code).toBe(-32602); + }); + + it("version-mismatch → -32001 with supported list", async () => { + const store = await makeStore(); + const res = await handleBridgeRequest(store, req("capabilities.negotiate", { + protocol: { supported: ["9.9"] }, + client: { name: "x", version: "1" }, + capabilities: {}, + })); + expect(res.error?.code).toBe(-32001); + expect(res.error?.data?.["supported"]).toContain("0.1"); + }); + + it("extensions.list filters + extensions.get returns document", async () => { + const store = await makeStore(); + await negotiate(store); + const list = await handleBridgeRequest(store, req("extensions.list", {})); + const exts = (list.result as { extensions: { id: string }[] }).extensions; + expect(exts.map((e) => e.id)).toContain("plug@1.0.0"); + + const get = await handleBridgeRequest(store, req("extensions.get", { id: "plug@1.0.0" })); + const doc = (get.result as { document: Record }).document; + expect(doc["name"]).toBe("plug"); + expect(get.error).toBeUndefined(); + + const missing = await handleBridgeRequest(store, req("extensions.get", { id: "nope@0.0.0" })); + expect(missing.error?.code).toBe(-32004); + }); + + it("skills.materialize → store target copies skill to shared root", async () => { + const store = await makeStore(); + await negotiate(store); + const res = await handleBridgeRequest(store, req("skills.materialize", { extension: "plug@1.0.0", target: "store" })); + expect(res.error).toBeUndefined(); + const skills = (res.result as { skills: { name: string; materializedTo?: string }[] }).skills; + expect(skills.map((s) => s.name)).toContain("helper"); + expect((await store.ports.fs.stat(`${ROOT}/skills/helper/SKILL.md`))?.type).toBe("file"); + }); + + it("commands.resolve returns expansion + argv", async () => { + const store = await makeStore(); + await negotiate(store); + const res = await handleBridgeRequest(store, req("commands.resolve", { text: "/lint --fix" })); + expect(res.error).toBeUndefined(); + const r = res.result as { expansion: { prompt: string }; command: { argv: string[] } }; + expect(r.expansion.prompt).toContain("Lint it."); + expect(r.command.argv).toEqual(["--fix"]); + }); + + it("tools.call without mcp:managed → capability-unsupported", async () => { + const store = await makeStore(); + await negotiate(store); + const res = await handleBridgeRequest(store, req("tools.call", { server: "s", tool: "t", arguments: {} })); + expect(res.error?.code).toBe(-32003); + }); + + it("unknown method → -32601; notifications not answered (placeholder ok)", async () => { + const store = await makeStore(); + const res = await handleBridgeRequest(store, req("nope.method", {})); + expect(res.error?.code).toBe(-32601); + const notif = await handleBridgeRequest(store, { jsonrpc: "2.0", method: "events.notify", params: { kind: "extensions.changed" } }); + expect(notif.result).toBeTruthy(); + }); + + it("hooks.invoke runs matching hook entries and merges status", async () => { + const ports = createTestPorts(); + ports.fs.putFile( + "/src/hk/plugin.json", + JSON.stringify({ $schema: "x", name: "hk", version: "1.0.0", extensions: { "dev.anyharness": { namespaceVersion: 1 } } }), + ); + ports.fs.putFile("/src/hk/dev.anyharness/hooks.json", JSON.stringify({ hooks: { "tool.before": [{ command: "hook-runner" }] } })); + // Approve hook exec for the daemon actor: nonInteractive=allow. + ports.fs.putFile(`${ROOT}/harness/config.toml`, `[policy.exec]\nnonInteractive = "allow"\n`); + ports.exec.handler.set("hook-runner", () => ({ code: 0, stdout: JSON.stringify({ status: "modify", output: { patched: true } }), stderr: "" })); + const store = createStore(ROOT, ports); + await store.install("/src/hk"); + const session = createBridgeSession(store); + await negotiate(store, session); + const res = await handleBridgeRequest(store, req("hooks.invoke", { + event: "tool.before", + input: { tool: "x" }, + }), session); + expect(res.error).toBeUndefined(); + expect((res.result as { status: string }).status).toBe("modify"); + expect(ports.exec.calls.some((c) => c.cmd === "hook-runner")).toBe(true); + }); +}); diff --git a/packages/sdk/src/bridge.ts b/packages/sdk/src/bridge.ts new file mode 100644 index 0000000..e9b1aad --- /dev/null +++ b/packages/sdk/src/bridge.ts @@ -0,0 +1,603 @@ +/** + * Bridge dispatch — `handleBridgeRequest(store, req, session?)` implements + * the server side of spec/bridge (protocol.md envelope + §3 handshake, + * operations.md op semantics, capabilities.md grant narrowing). + * + * Session state (negotiated caps, handshake latch, cancellation set) lives + * in a `BridgeSession` — `createBridgeSession(store)` for multi-caller + * transports (HTTP daemon); bare `handleBridgeRequest(store, req)` uses a + * stable per-store default session, which is exactly the stdio/`harness + * serve` shape: one process, one session. + */ + +import { StoreError } from "./errors.js"; +import { parseFrontmatter } from "./frontmatter.js"; +import { listPackageFiles } from "./integrity.js"; +import { join } from "./path.js"; +import { resolveExecDecision } from "./policy.js"; +import type { Store, StoreNotification } from "./store.js"; +import type { Capabilities, Extension, ExtensionKind, ManifestRef } from "./types.js"; +import { EXTENSION_KINDS } from "./types.js"; + +/* --------------------------- JSON-RPC types ------------------------- */ + +export type RequestId = string | number; + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: RequestId; + method: string; + params?: Record; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: { kind: string } & Record; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: RequestId | null; + result?: unknown; + error?: JsonRpcError; +} + +/** Pinned op names — plan + spec/bridge/operations.md. */ +export const BRIDGE_OPS = [ + "capabilities.negotiate", + "extensions.list", + "extensions.get", + "hooks.invoke", + "commands.resolve", + "skills.materialize", + "tools.call", + "events.notify", +] as const; +export type BridgeOp = (typeof BRIDGE_OPS)[number]; + +/* ------------------------------ errors ------------------------------ */ + +const err = (code: number, kind: string, message: string, data?: Record): JsonRpcError => ({ + code, + message, + data: { kind, ...data }, +}); + +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; +const KIND_TO_CODE: Record = { + "version-mismatch": -32001, + "handshake-required": -32002, + "capability-unsupported": -32003, + "not-found": -32004, + "manifest-invalid": -32005, + "hook-failed": -32006, + "policy-denied": -32007, + "trust-violation": -32008, + "auth-failed": -32009, + "transport-unavailable": -32010, + conflict: -32011, + forbidden: -32012, + "request-cancelled": -32800, + mcp: -32603, +}; + +const toRpcError = (e: unknown): JsonRpcError => { + if (e instanceof StoreError) + return err(KIND_TO_CODE[e.kind] ?? INTERNAL_ERROR, e.kind, e.message, e.data); + if (e instanceof RpcError) return err(e.code, e.kind, e.message, e.data); + return err(INTERNAL_ERROR, "internal", (e as Error)?.message ?? "internal error"); +}; + +class RpcError extends Error { + readonly code: number; + readonly kind: string; + readonly data?: Record; + constructor(code: number, kind: string, message: string, data?: Record) { + super(message); + this.code = code; + this.kind = kind; + this.data = data; + } +} + +const invalidParams = (msg: string, data?: Record): RpcError => + new RpcError(INVALID_PARAMS, "invalid-params", msg, data); +const capabilityUnsupported = (capability: string): RpcError => + new RpcError(-32003, "capability-unsupported", `capability not granted: ${capability}`, { capability }); +const rpcNotFound = (resource: string, name?: string): RpcError => + new RpcError(-32004, "not-found", `${resource} not found${name ? `: ${name}` : ""}`, { resource, name }); + +/* ------------------------------ session ----------------------------- */ + +export const PROTOCOL_VERSIONS = ["0.1"] as const; +export const SERVER_NAME = "anyharness"; +export const SERVER_SPEC_VERSIONS: Record = { + manifest: ["1.0"], + storeLayout: ["0.1"], + lockfile: ["0.1"], + trust: ["0.1"], +}; + +/** Slot ceilings this server can grant (capabilities.md §1 ordering). */ +const SLOT_CEILING: Record = { + storage: ["fs", "kv", "none"], + secrets: ["host", "prompt", "none"], + exec: "boolean", + skills: ["read-write", "read", "none"], + mcp: ["external", "none"], // no managed MCP in v0 — see report +}; + +export interface BridgeSession { + negotiated: boolean; + granted?: Capabilities; + client?: { name: string; version: string }; + specs?: Record; + cancelled: Set; +} + +const defaultSessions = new WeakMap(); + +export const createBridgeSession = (_store: Store): BridgeSession => ({ + negotiated: false, + cancelled: new Set(), +}); + +const sessionFor = (store: Store, session?: BridgeSession): BridgeSession => { + if (session) return session; + let s = defaultSessions.get(store); + if (!s) { + s = createBridgeSession(store); + defaultSessions.set(store, s); + } + return s; +}; + +/* --------------------------- capability grant ------------------------ */ + +const narrower = (declared: unknown, ceiling: string[] | "boolean"): unknown => { + if (ceiling === "boolean") return declared === true; + if (typeof declared !== "string") return ceiling[ceiling.length - 1]; + const di = ceiling.indexOf(declared); + return di < 0 ? ceiling[ceiling.length - 1] : declared; +}; + +const grantCapabilities = (declared: Capabilities): Capabilities => { + const kinds = (declared.kinds ?? []).filter((k) => EXTENSION_KINDS.includes(k)); + const slots: Capabilities["slots"] = { + storage: narrower(declared.slots?.storage, SLOT_CEILING.storage) as Capabilities["slots"]["storage"], + secrets: narrower(declared.slots?.secrets, SLOT_CEILING.secrets) as Capabilities["slots"]["secrets"], + exec: narrower(declared.slots?.exec, SLOT_CEILING.exec) as boolean, + skills: narrower(declared.slots?.skills, SLOT_CEILING.skills) as Capabilities["slots"]["skills"], + mcp: narrower(declared.slots?.mcp, SLOT_CEILING.mcp) as Capabilities["slots"]["mcp"], + }; + return { kinds, hookEvents: [...(declared.hookEvents ?? [])], slots }; +}; + +const extensionSupported = (ext: Extension, granted: Capabilities): boolean => + granted.kinds.includes(ext.kind) || + (ext.provides ?? []).some((k) => granted.kinds.includes(k)); + +/* --------------------------------- ops -------------------------------- */ + +const p = (req: JsonRpcRequest): Record => + (req.params ?? {}) as Record; + +const negotiate = (session: BridgeSession, params: Record): unknown => { + if (session.negotiated) throw invalidParams("capabilities.negotiate already completed for this session"); + const protocol = params["protocol"]; + const client = params["client"]; + const caps = params["capabilities"]; + if (typeof protocol !== "object" || protocol === null || + !Array.isArray((protocol as Record)["supported"])) + throw invalidParams("protocol.supported must be a non-empty array"); + const supported = (protocol as Record)["supported"] as unknown[]; + const version = supported.find( + (v): v is string => typeof v === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(v), + ); + if (version === undefined) + throw new RpcError(-32001, "version-mismatch", "no common protocol version", { + supported: [...PROTOCOL_VERSIONS], + }); + if (typeof client !== "object" || client === null) + throw invalidParams("client {name,version} required"); + if (typeof caps !== "object" || caps === null) + throw invalidParams("capabilities object required"); + + const declared = caps as Capabilities; + const granted = grantCapabilities(declared); + + const specsIn = (params["specs"] ?? {}) as Record; + const specsOut: Record = {}; + for (const [spec, offered] of Object.entries(specsIn)) { + const ours = SERVER_SPEC_VERSIONS[spec]; + if (!ours || !Array.isArray(offered)) continue; + const pick = offered.find((v): v is string => typeof v === "string" && ours.includes(v)); + if (pick) specsOut[spec] = pick; + } + + session.negotiated = true; + session.granted = granted; + session.client = client as { name: string; version: string }; + session.specs = specsOut; + + return { + protocol: { version }, + specs: specsOut, + server: { name: SERVER_NAME, version: "2.0.0" }, + session: { id: `ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` }, + capabilities: granted, + }; +}; + +const extensionsList = async (store: Store, session: BridgeSession, params: Record): Promise => { + const granted = session.granted!; + const kinds = params["kinds"] as ExtensionKind[] | undefined; + const includeUnsupported = params["includeUnsupported"] === true; + const enabledOnly = params["enabledOnly"] !== false; + const all = await store.list({ enabledOnly }); + const kindFiltered = kinds?.length + ? all.filter((e) => kinds.includes(e.kind) || (e.provides ?? []).some((k) => kinds.includes(k))) + : all; + const extensions = kindFiltered + .map((e) => { + const supported = extensionSupported(e, granted); + if (!supported && !includeUnsupported) return null; + return supported ? e : { ...e, supported: false }; + }) + .filter((e): e is Extension => e !== null); + return { extensions }; +}; + +const extensionsGet = async (store: Store, session: BridgeSession, params: Record): Promise => { + const granted = session.granted!; + const lock = await store.readLock(); + let name: string | undefined; + if (typeof params["id"] === "string") { + const id = params["id"]; + const at = id.lastIndexOf("@"); + const candidate = at > 0 ? id.slice(0, at) : id; + if (candidate in lock.extensions) name = candidate; + else if (id in lock.extensions) name = id; + } else if (typeof params["manifest"] === "object" && params["manifest"] !== null) { + const m = params["manifest"] as ManifestRef; + if (typeof m.name === "string" && m.name in lock.extensions) name = m.name; + } else { + throw invalidParams("exactly one selector required: id | manifest"); + } + if (name === undefined) throw rpcNotFound("extension", JSON.stringify(params["id"] ?? params["manifest"])); + const entry = lock.extensions[name]; + const ext = (await store.get(name)).extension; + if (!extensionSupported(ext, granted)) throw capabilityUnsupported(`kinds:${ext.kind}`); + + // Manifest document: plugin.json verbatim; standalone skills synthesize one. + let document: Record; + if (entry.kind === "skill" && entry.targets.includes("skills")) { + const docPath = join((await store.get(name)).packageDir, "SKILL.md"); + let fmDoc: Record = { name, version: entry.manifest.version }; + try { + const text = new TextDecoder().decode(await store.ports.fs.readFile(docPath)); + const { frontmatter } = parseFrontmatter(text); + fmDoc = { ...fmDoc, name: frontmatter["name"] ?? name, version: frontmatter["version"] ?? entry.manifest.version } as Record; + } catch { /* manifest read failure → synthetic ref */ } + document = fmDoc; + } else { + const pkgDir = (await store.get(name)).packageDir; + let text: string; + try { + text = new TextDecoder().decode(await store.ports.fs.readFile(join(pkgDir, "plugin.json"))); + } catch { + throw new RpcError(-32005, "manifest-invalid", `plugin.json unreadable for ${name}`, { issues: ["read failure"] }); + } + try { + document = JSON.parse(text) as Record; + } catch (e) { + throw new RpcError(-32005, "manifest-invalid", `plugin.json invalid for ${name}`, { issues: [(e as Error).message] }); + } + } + + const files = await listPackageFiles(store.ports.fs, (await store.get(name)).packageDir); + return { extension: ext, document, files }; +}; + +/* hooks.invoke ------------------------------------------------------- */ + +interface HookRunResult { + extension: string; + status: "continue" | "modify" | "block" | "skipped" | "error"; + output?: Record; + skippedReason?: "policy" | "capability" | "unsupported-event"; + durationMs?: number; +} + +const hooksInvoke = async ( + store: Store, + session: BridgeSession, + params: Record, + emit?: (n: StoreNotification) => void, +): Promise => { + const granted = session.granted!; + const event = params["event"]; + const input = params["input"]; + if (typeof event !== "string" || event === "") throw invalidParams("event required"); + if (typeof input !== "object" || input === null) throw invalidParams("input object required"); + if (!granted.hookEvents.includes(event)) throw capabilityUnsupported(event); + + const context = (params["context"] ?? {}) as Record; + const onlyExtension = typeof context["extension"] === "string" ? context["extension"] : undefined; + const stream = params["stream"] === true; + const timeoutMs = typeof params["timeoutMs"] === "number" ? params["timeoutMs"] : undefined; + const streamId = String(params["__id"] ?? ""); + + const policy = await store.policy(); + const lock = await store.readLock(); + const results: HookRunResult[] = []; + + for (const [name, entry] of Object.entries(lock.extensions)) { + if (entry.enabled === false) continue; + const id = `${name}@${entry.manifest.version}`; + if (onlyExtension !== undefined && onlyExtension !== id && onlyExtension !== name) continue; + + const pkgDir = (await store.get(name)).packageDir; + const hooksPath = join(pkgDir, "dev.anyharness", "hooks.json"); + let hooksDoc: Record; + try { + hooksDoc = JSON.parse(new TextDecoder().decode(await store.ports.fs.readFile(hooksPath))); + } catch { + continue; // no hooks.json → contributes nothing + } + const hooks = (hooksDoc as { hooks?: Record })["hooks"]; + const entries = typeof hooks === "object" && hooks !== null ? hooks[event] : undefined; + if (!Array.isArray(entries) || entries.length === 0) continue; + + for (const raw of entries) { + const startedAt = Date.now(); + if (stream && emit) + emit({ kind: "hook.progress", streamId, data: { extension: id, stage: "started" } }); + + const cmd = typeof raw === "object" && raw !== null ? (raw as Record)["command"] : undefined; + if (typeof cmd !== "string") { + results.push({ extension: id, status: "error" }); + continue; + } + + // Trust gate: exec policy + slots.exec (capabilities.md §4.5). + if (granted.slots.exec !== true) { + results.push({ extension: id, status: "skipped", skippedReason: "policy" }); + continue; + } + const decision = resolveExecDecision(policy, "hooks", "daemon", entry.source); + if (decision === "deny") { + results.push({ extension: id, status: "skipped", skippedReason: "policy" }); + continue; + } + + // `./`-prefixed commands resolve inside the package (containment §6.3). + const command = cmd.startsWith("./") ? join(pkgDir, cmd.slice(2)) : cmd; + const args = Array.isArray((raw as Record)["args"]) + ? ((raw as Record)["args"] as unknown[]).filter((a): a is string => typeof a === "string") + : []; + const dataDir = join(store.paths.data, name); + const expandedArgs = args.map((a) => + a.replaceAll("${PLUGIN_ROOT}", pkgDir).replaceAll("${PLUGIN_DATA}", dataDir)); + const timeout = typeof (raw as Record)["timeout"] === "number" + ? ((raw as Record)["timeout"] as number) * 1000 + : timeoutMs; + + try { + const res = await store.ports.exec.run(command, expandedArgs, { + cwd: pkgDir, + env: { PLUGIN_ROOT: pkgDir, PLUGIN_DATA: dataDir }, + stdin: JSON.stringify({ event, input, context }), + timeoutMs: timeout, + }); + if (session.cancelled.has(streamId)) { + results.push({ extension: id, status: "skipped", skippedReason: "capability" }); + continue; + } + if (res.code !== 0) { + results.push({ extension: id, status: "error", durationMs: Date.now() - startedAt }); + continue; + } + let parsed: { status?: string; output?: Record } = {}; + try { + parsed = JSON.parse(res.stdout) as typeof parsed; + } catch { + parsed = { status: "continue" }; + } + const status = parsed.status === "block" || parsed.status === "modify" ? parsed.status : "continue"; + results.push({ + extension: id, + status, + output: parsed.output, + durationMs: Date.now() - startedAt, + }); + if (stream && emit && typeof parsed.output?.["delta"] === "string") + emit({ kind: "hook.delta", streamId, data: { extension: id, chunk: parsed.output["delta"] } }); + } catch { + results.push({ extension: id, status: "error", durationMs: Date.now() - startedAt }); + } + } + } + + const merged = results.some((r) => r.status === "block") + ? "block" + : results.some((r) => r.status === "modify") + ? "modify" + : "continue"; + const output = merged === "modify" + ? Object.assign({}, ...results.filter((r) => r.status === "modify").map((r) => r.output ?? {})) + : undefined; + return { status: merged, output, results }; +}; + +/* commands.resolve ---------------------------------------------------- */ + +const commandsResolve = async (store: Store, _session: BridgeSession, params: Record): Promise => { + let name: string | undefined; + let argv: string[] | undefined; + if (typeof params["name"] === "string") { + name = params["name"]; + argv = Array.isArray(params["argv"]) + ? (params["argv"] as unknown[]).filter((a): a is string => typeof a === "string") + : undefined; + } else if (typeof params["text"] === "string") { + const text = params["text"].trim().replace(/^\//, ""); + const tokens = text.split(/\s+/).filter(Boolean); + name = tokens.shift(); + argv = tokens; + } else { + throw invalidParams("exactly one selector required: name | text"); + } + if (!name) throw invalidParams("empty command name"); + + const lock = await store.readLock(); + for (const [extName, entry] of Object.entries(lock.extensions)) { + if (entry.enabled === false) continue; + if (!entry.components?.some((c) => c.kind === "command" && c.name === name)) continue; + const pkgDir = (await store.get(extName)).packageDir; + const dir = join(pkgDir, "dev.anyharness", "commands"); + let candidates: string[] = []; + try { + candidates = (await store.ports.fs.readDir(dir)) + .filter((e) => e.type === "file" && e.name.endsWith(".md")) + .map((e) => join(dir, e.name)); + } catch { + continue; + } + for (const mdPath of candidates) { + let text: string; + try { + text = new TextDecoder().decode(await store.ports.fs.readFile(mdPath)); + } catch { + continue; + } + const { frontmatter, body } = parseFrontmatter(text); + const stem = mdPath.slice(mdPath.lastIndexOf("/") + 1).replace(/\.md$/, ""); + const declared = typeof frontmatter["name"] === "string" ? frontmatter["name"] : stem; + if (declared !== name && stem !== name) continue; + return { + command: { + name, + source: { extension: `${extName}@${entry.manifest.version}`, manifest: entry.manifest }, + description: + typeof frontmatter["description"] === "string" ? frontmatter["description"] : undefined, + argv, + }, + expansion: { prompt: body }, // body verbatim per manifest.md §5.2 + }; + } + } + throw rpcNotFound("command", name); +}; + +/* skills.materialize -------------------------------------------------- */ + +const skillsMaterialize = async (store: Store, session: BridgeSession, params: Record): Promise => { + const granted = session.granted!; + const extId = params["extension"]; + if (typeof extId !== "string" || extId === "") throw invalidParams("extension id required"); + const target = params["target"] === "inline" ? "inline" : "store"; + const include = Array.isArray(params["include"]) + ? (params["include"] as unknown[]).filter((s): s is string => typeof s === "string") + : undefined; + + if (target === "store") { + const skillsSlot = granted.slots.skills; + if (skillsSlot !== "read" && skillsSlot !== "read-write") + throw capabilityUnsupported("skills"); + if (granted.slots.storage !== "fs") throw capabilityUnsupported("storage"); + } + + const res = await store.materialize(extId, { target, include, actor: "daemon" }); + return { skills: res.skills }; +}; + +/* tools.call ----------------------------------------------------------- */ + +const toolsCall = async (store: Store, session: BridgeSession, params: Record): Promise => { + const granted = session.granted!; + if (granted.slots.mcp !== "managed") + throw capabilityUnsupported("mcp"); + const server = params["server"]; + const tool = params["tool"]; + const args = params["arguments"]; + if (typeof server !== "string" || typeof tool !== "string" || typeof args !== "object" || args === null) + throw invalidParams("server, tool, arguments required"); + const mcp = store.ports.mcp; + if (mcp === undefined) + throw new RpcError(-32010, "transport-unavailable", "no managed MCP runtime on this server", { capability: "mcp" }); + try { + return await mcp.call(server, tool, args as Record, params["meta"] as Record | undefined); + } catch (e) { + const mcpError = typeof e === "object" && e !== null ? e : { message: String(e) }; + throw new RpcError(-32603, "mcp", "MCP tool call failed", { mcpError }); + } +}; + +/* ------------------------------- dispatch ---------------------------- */ + +/** + * Pinned dispatcher. `session` is optional: transports serving many + * sessions (HTTP daemon) pass one per connection; stdio/in-process use + * the per-store default — one process, one negotiation. + * + * Notifications (`"id" absent`) are handled but MUST NOT be answered by + * the transport — the returned response is a discardable placeholder. + */ +export async function handleBridgeRequest( + store: Store, + req: JsonRpcRequest, + session?: BridgeSession, + emit?: (n: StoreNotification) => void, +): Promise { + const id = req.id ?? null; + const sess = sessionFor(store, session); + + const fail = (e: unknown): JsonRpcResponse => ({ jsonrpc: "2.0", id, error: toRpcError(e) }); + + if (req === null || typeof req !== "object" || req.jsonrpc !== "2.0" || typeof req.method !== "string") + return fail(new RpcError(INVALID_REQUEST, "invalid-request", "not a JSON-RPC 2.0 envelope")); + + // Notifications: no id → never answered (protocol.md §2). The only + // client→server kind honored is request.cancelled; others are ignored. + if (req.id === undefined) { + if (req.method === "events.notify") { + const params = p(req); + if (params["kind"] === "request.cancelled") { + const rid = (params["data"] as Record | undefined)?.["requestId"]; + if (rid !== undefined) sess.cancelled.add(String(rid)); + } + } + return { jsonrpc: "2.0", id: null, result: { ok: true } }; + } + + if (!(BRIDGE_OPS as readonly string[]).includes(req.method)) + return fail(new RpcError(METHOD_NOT_FOUND, "method-not-found", `unknown method: ${req.method}`)); + + if (req.method !== "capabilities.negotiate" && !sess.negotiated) + return fail(new RpcError(-32002, "handshake-required", "capabilities.negotiate must be the first request")); + + try { + const params = p(req); + params["__id"] = id; // internal: lets hooks.invoke correlate streamId + let result: unknown; + switch (req.method) { + case "capabilities.negotiate": result = negotiate(sess, params); break; + case "extensions.list": result = await extensionsList(store, sess, params); break; + case "extensions.get": result = await extensionsGet(store, sess, params); break; + case "hooks.invoke": result = await hooksInvoke(store, sess, params, emit); break; + case "commands.resolve": result = await commandsResolve(store, sess, params); break; + case "skills.materialize": result = await skillsMaterialize(store, sess, params); break; + case "tools.call": result = await toolsCall(store, sess, params); break; + case "events.notify": result = { ok: true }; break; + default: result = undefined; + } + return { jsonrpc: "2.0", id, result }; + } catch (e) { + return fail(e); + } +} diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 0000000..8520b32 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,57 @@ +/** + * Internal error vocabulary. `StoreError` carries a machine-readable + * `kind` that `handleBridgeRequest` maps onto the protocol's error table + * (spec/bridge/protocol.md §4.2). + */ + +export type ErrorKind = + | "version-mismatch" + | "handshake-required" + | "capability-unsupported" + | "not-found" + | "manifest-invalid" + | "hook-failed" + | "policy-denied" + | "trust-violation" + | "auth-failed" + | "transport-unavailable" + | "conflict" + | "forbidden" + | "request-cancelled" + | "mcp" + | "invalid" + | "internal"; + +export class StoreError extends Error { + readonly kind: ErrorKind; + readonly data?: Record; + constructor(kind: ErrorKind, message: string, data?: Record) { + super(message); + this.name = "StoreError"; + this.kind = kind; + this.data = data; + } +} + +export const notFound = (resource: string, name: string): StoreError => + new StoreError("not-found", `${resource} not found: ${name}`, { + resource, + name, + }); + +export const policyDenied = (policy: string, message: string): StoreError => + new StoreError("policy-denied", message, { policy }); + +export const trustViolation = ( + expected: string | undefined, + actual: string, + message?: string, +): StoreError => + new StoreError( + "trust-violation", + message ?? "integrity check failed", + { expected, actual }, + ); + +export const conflict = (message: string): StoreError => + new StoreError("conflict", message); diff --git a/packages/sdk/src/frontmatter.ts b/packages/sdk/src/frontmatter.ts new file mode 100644 index 0000000..030653c --- /dev/null +++ b/packages/sdk/src/frontmatter.ts @@ -0,0 +1,69 @@ +/** + * YAML-frontmatter reader for component `.md` files (commands, agents, + * rules) and `SKILL.md`. Supports the converged subset those formats use: + * `key: scalar`, `key: [a, b]`, and block lists (`key:` then `- item` + * lines). Values are strings or string arrays — numbers/booleans come + * back as their textual form, matching how these formats are consumed. + */ + +export interface Frontmatter { + [key: string]: string | string[] | undefined; +} + +export interface FrontmatterDocument { + frontmatter: Frontmatter; + body: string; +} + +const parseInlineList = (raw: string): string[] | null => { + const trimmed = raw.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null; + const inner = trimmed.slice(1, -1).trim(); + if (inner === "") return []; + return inner.split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")); +}; + +const unquote = (s: string): string => s.trim().replace(/^["']|["']$/g, ""); + +/** Parse a `---` fenced frontmatter block + body. Absent block → empty fm. */ +export const parseFrontmatter = (text: string): FrontmatterDocument => { + const normalized = text.replace(/^\uFEFF/, ""); + if (!normalized.startsWith("---")) return { frontmatter: {}, body: normalized }; + const firstLineEnd = normalized.indexOf("\n"); + if (firstLineEnd < 0) return { frontmatter: {}, body: normalized }; + if (normalized.slice(0, firstLineEnd).trim() !== "---") + return { frontmatter: {}, body: normalized }; + const close = normalized.indexOf("\n---", firstLineEnd); + if (close < 0) return { frontmatter: {}, body: normalized }; + const fmText = normalized.slice(firstLineEnd + 1, close); + const bodyStart = normalized.indexOf("\n", close + 1); + const body = bodyStart < 0 ? "" : normalized.slice(bodyStart + 1); + + const frontmatter: Frontmatter = {}; + const fmLines = fmText.split("\n"); + let listKey: string | null = null; + for (const rawLine of fmLines) { + const line = rawLine.replace(/\s+$/, ""); + if (line.trim() === "" || line.trim().startsWith("#")) continue; + const listItem = line.match(/^\s+-\s+(.*)$/); + if (listItem && listKey !== null) { + (frontmatter[listKey] as string[]).push(unquote(listItem[1])); + continue; + } + const kv = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (!kv) { + listKey = null; + continue; + } + const [, key, rawValue] = kv; + if (rawValue.trim() === "") { + frontmatter[key] = []; + listKey = key; + continue; + } + const inline = parseInlineList(rawValue); + frontmatter[key] = inline ?? unquote(rawValue); + listKey = null; + } + return { frontmatter, body }; +}; diff --git a/packages/sdk/src/glob.ts b/packages/sdk/src/glob.ts new file mode 100644 index 0000000..70b8a55 --- /dev/null +++ b/packages/sdk/src/glob.ts @@ -0,0 +1,33 @@ +/** + * URI glob matching for `sources.allow` / `sources.deny` (trust.md §4.1). + * `*` matches within a path segment, `**` crosses segments, `?` is a + * single non-separator char. Everything else is literal. + */ + +const escapeRe = (s: string): string => s.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + +export const globToRegExp = (glob: string): RegExp => { + let re = ""; + for (let i = 0; i < glob.length; i++) { + const ch = glob[i]; + if (ch === "*") { + if (glob[i + 1] === "*") { + re += ".*"; + i++; + } else { + re += "[^/]*"; + } + } else if (ch === "?") { + re += "[^/]"; + } else { + re += escapeRe(ch); + } + } + return new RegExp(`^${re}$`); +}; + +export const matchesGlob = (glob: string, value: string): boolean => + globToRegExp(glob).test(value); + +export const matchesAnyGlob = (globs: string[], value: string): boolean => + globs.some((g) => matchesGlob(g, value)); diff --git a/packages/sdk/src/hash.test.ts b/packages/sdk/src/hash.test.ts new file mode 100644 index 0000000..6481b34 --- /dev/null +++ b/packages/sdk/src/hash.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { sha256, sha256HexOfText, sriSha256, toBase64, toHex } from "./hash.js"; + +// RFC 6234 / FIPS 180-4 known answers. +const vectors: [string, string][] = [ + ["", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"], + ["abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"], + [ + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1", + ], +]; + +describe("sha256", () => { + it("matches FIPS vectors", () => { + for (const [input, hex] of vectors) { + expect(toHex(sha256(new TextEncoder().encode(input)))).toBe(hex); + } + }); + + it("handles >64-byte and >1MB inputs", () => { + const big = new Uint8Array(1_000_000).fill(97); + const digest = toHex(sha256(big)); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + }); + + it("sha256HexOfText hashes utf-8 text", () => { + expect(sha256HexOfText("abc")).toBe(vectors[1]![1]); + }); + + it("sriSha256 emits sha256-", () => { + const sri = sriSha256(new TextEncoder().encode("")); + expect(sri.startsWith("sha256-")).toBe(true); + expect(toBase64(sha256(new TextEncoder().encode("")))).toBe(sri.slice(7)); + }); +}); diff --git a/packages/sdk/src/hash.ts b/packages/sdk/src/hash.ts new file mode 100644 index 0000000..7599b4a --- /dev/null +++ b/packages/sdk/src/hash.ts @@ -0,0 +1,120 @@ +/** + * Pure-TypeScript SHA-256. + * + * The isomorphic rule forbids node builtins crypto, and `crypto.subtle` is only + * guaranteed on secure contexts — a bundled implementation has no ambient + * requirement at all and produces identical digests on every target + * (npm, scriptc binary, browser bundle). + */ + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const H0 = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, +]); + +const rotr = (x: number, n: number): number => + ((x >>> n) | (x << (32 - n))) >>> 0; + +/** SHA-256 of raw bytes, returned as a 32-byte digest. */ +export function sha256(data: Uint8Array): Uint8Array { + const h = H0.slice(); + const bitLen = data.length * 8; + // Padded length: message + 0x80 + pad + 8-byte length, multiple of 64. + const padded = new Uint8Array( + (Math.floor((data.length + 8) / 64) + 1) * 64, + ); + padded.set(data); + padded[data.length] = 0x80; + const view = new DataView(padded.buffer); + // 64-bit length; we only write the low 32 bits' worth plus high word (0). + view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000)); + view.setUint32(padded.length - 4, bitLen >>> 0); + + const w = new Uint32Array(64); + for (let off = 0; off < padded.length; off += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + + const out = new Uint8Array(32); + const ov = new DataView(out.buffer); + for (let i = 0; i < 8; i++) ov.setUint32(i * 4, h[i]); + return out; +} + +const HEX = "0123456789abcdef"; + +export const toHex = (bytes: Uint8Array): string => { + let s = ""; + for (const b of bytes) s += HEX[b >>> 4] + HEX[b & 0xf]; + return s; +}; + +const B64 = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +export const toBase64 = (bytes: Uint8Array): string => { + let s = ""; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]; + const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0; + const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0; + s += B64[b0 >>> 2]; + s += B64[((b0 & 3) << 4) | (b1 >>> 4)]; + s += i + 1 < bytes.length ? B64[((b1 & 0xf) << 2) | (b2 >>> 6)] : "="; + s += i + 2 < bytes.length ? B64[b2 & 0x3f] : "="; + } + return s; +}; + +const encoder = new TextEncoder(); + +/** SRI-syntax digest per spec/lockfile.md §4: `sha256-`. */ +export const sriSha256 = (data: Uint8Array): string => + `sha256-${toBase64(sha256(data))}`; + +export const sha256HexOfText = (text: string): string => + toHex(sha256(encoder.encode(text))); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cb0ff5c..415fbed 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1 +1,101 @@ -export {}; +/** + * @any-harness/sdk — isomorphic core of the AnyHarness store / trust / + * bridge layer. Zero node builtins imports: every IO crosses `StorePorts` + * (ports.ts). The pinned public API for sibling packages (cli, metaharness): + * + * createStore(root, ports[, options]) → Store + * resolveSource(input) → SourceRef + * handleBridgeRequest(store, req[, session]) → Promise + * + * Store methods: list, get, install, remove, setEnabled, materialize, + * verify, doctor (+ policy, auditLog, readLock, subscribe helpers). + */ + +// Pinned API surface. +export { createStore, storePaths, SHARED_SKILLS_TARGET } from "./store.js"; +export type { + ApproveExec, + DoctorFinding, + DoctorReport, + InstallOptions, + InstallResult, + ListOptions, + MaterializeOptions, + MaterializeResult, + MaterializedSkill, + Store, + StoreEntry, + StoreNotification, + StoreOptions, + StorePaths, + VerifyResult, +} from "./store.js"; +export { createBridgeSession, handleBridgeRequest, BRIDGE_OPS } from "./bridge.js"; +export type { + BridgeOp, + BridgeSession, + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, + RequestId, +} from "./bridge.js"; +export { resolveSource, cloneUrlFor } from "./sources.js"; + +// Ports — sibling packages implement/adapt these. +export { FsError } from "./ports.js"; +export type { + ExecOptions, + ExecPort, + ExecResult, + FsDirent, + FsPort, + FsStat, + McpPort, + StorePorts, +} from "./ports.js"; + +// Core types (pinned bridge + lockfile + trust shapes). +export { EXTENSION_KINDS } from "./types.js"; +export type { + Actor, + AuditEvent, + AuditRecord, + Capabilities, + ComponentRef, + Extension, + ExtensionKind, + LockEntry, + Lockfile, + ManifestRef, + SourceRef, + TrustPolicy, +} from "./types.js"; + +// Errors + utilities exposed for cli/testing. +export { StoreError } from "./errors.js"; +export type { ErrorKind } from "./errors.js"; +export { readLockfile, serializeLockfile, emptyLockfile, LOCKFILE_VERSION } from "./lockfile.js"; +export type { LockfileRead } from "./lockfile.js"; +export { computeIntegrity, listPackageFiles } from "./integrity.js"; +export type { PackageFile } from "./integrity.js"; +export { validateManifest, inspectPackage, isValidExtensionName, SDK_VERSION, NAMESPACE_VERSION } from "./manifest.js"; +export type { HookDeclaration, McpServerDecl, PackageInspection, ParsedManifest } from "./manifest.js"; +export { DEFAULT_POLICY, loadPolicy, parsePolicyText } from "./policy.js"; +export type { PolicyRead } from "./policy.js"; +export { sha256, toHex, toBase64, sriSha256, sha256HexOfText } from "./hash.js"; +export { join, normalize, isInside, resolveInside, compareUtf8 } from "./path.js"; +export { satisfiesRange, compareVersions, parseVersion } from "./semver.js"; +export { globToRegExp, matchesGlob, matchesAnyGlob } from "./glob.js"; +export { parseToml } from "./toml.js"; +export { parseFrontmatter } from "./frontmatter.js"; +export { atomicWriteFile, copyTree, renameIntoPlace, swapDirectory } from "./atomic.js"; +export { withLock, lockPathFor } from "./mutex.js"; +export type { MutexOptions } from "./mutex.js"; +export { appendAudit, readAudit } from "./audit.js"; +export { + mergeMcpServers, + readRootMcp, + removeMcpServers, + rootMcpPathFor, +} from "./mcpmerge.js"; +export type { McpMergeResult, RootMcpRead } from "./mcpmerge.js"; diff --git a/packages/sdk/src/integrity.ts b/packages/sdk/src/integrity.ts new file mode 100644 index 0000000..593562c --- /dev/null +++ b/packages/sdk/src/integrity.ts @@ -0,0 +1,110 @@ +/** + * Content integrity digests — spec/lockfile.md §4, SRI syntax. + * + * The hashed "manifest-of-files" is computed over the installed tree: + * every regular file's ` ` line, + * sorted by UTF-8 byte order of the path, joined with `\n`, then SHA-256 + * + base64'd into `sha256-`. Symlinks hash their *target string*; + * a dangling or root-escaping symlink aborts the digest. + */ + +import { FsPort } from "./ports.js"; +import { sha256, toBase64, toHex } from "./hash.js"; +import { compareUtf8, dirname, isInside, join, normalize } from "./path.js"; +import { StoreError, trustViolation } from "./errors.js"; + +const encoder = new TextEncoder(); + +export interface PackageFile { + /** `/`-separated path relative to the package root. */ + path: string; + size: number; +} + +/** + * Recursively enumerate regular files + symlinks under `root`. + * Paths are relative, `/`-separated, sorted by UTF-8 byte order. + */ +export const listPackageFiles = async ( + fs: FsPort, + root: string, +): Promise => { + const files: PackageFile[] = []; + const walk = async (dir: string, rel: string): Promise => { + let entries; + try { + entries = await fs.readDir(dir); + } catch { + return; // absent dirs contribute nothing + } + for (const entry of entries) { + const childAbs = join(dir, entry.name); + const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`; + if (entry.type === "directory") { + await walk(childAbs, childRel); + } else if (entry.type === "file" || entry.type === "symlink") { + const st = await fs.stat(childAbs); + files.push({ path: childRel, size: st?.size ?? 0 }); + } + // "other" entries (fifos, sockets) are not regular files — skipped. + } + }; + await walk(root, ""); + files.sort((a, b) => compareUtf8(a.path, b.path)); + return files; +}; + +/** + * Read the byte content a file contributes to the digest: file bytes, or + * for a symlink the *target string*'s bytes — but only when the target + * resolves to an existing entry still inside `pkgRoot` (containment, + * store-layout.md §6.3 + lockfile.md §4). + */ +const contentForDigest = async ( + fs: FsPort, + pkgRoot: string, + abs: string, + type: "file" | "symlink", +): Promise => { + if (type === "file") return fs.readFile(abs); + const target = await fs.readlink(abs); + const resolved = normalize( + target.startsWith("/") ? target : join(dirname(abs), target), + ); + if (!isInside(pkgRoot, resolved)) + throw trustViolation( + undefined, + abs, + `symlink escapes package root: ${abs} -> ${target}`, + ); + const st = await fs.stat(resolved); + if (st === null) + throw trustViolation( + undefined, + abs, + `dangling symlink: ${abs} -> ${target}`, + ); + return encoder.encode(target); +}; + +/** + * SRI digest over the package tree at `pkgRoot`. Throws `trust-violation` + * when a symlink dangles or escapes the package root. + */ +export const computeIntegrity = async ( + fs: FsPort, + pkgRoot: string, +): Promise => { + const files = await listPackageFiles(fs, pkgRoot); + const lines: string[] = []; + for (const file of files) { + const abs = join(pkgRoot, file.path); + const st = await fs.stat(abs); + if (st === null) throw new StoreError("internal", `vanished during digest: ${abs}`); + const type = st.type === "symlink" ? "symlink" : "file"; + const contents = await contentForDigest(fs, pkgRoot, abs, type); + lines.push(`${toHex(sha256(contents))} ${file.path}`); + } + const manifestBytes = encoder.encode(lines.join("\n")); + return `sha256-${toBase64(sha256(manifestBytes))}`; +}; diff --git a/packages/sdk/src/lockfile.test.ts b/packages/sdk/src/lockfile.test.ts new file mode 100644 index 0000000..015f5cc --- /dev/null +++ b/packages/sdk/src/lockfile.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { computeIntegrity, listPackageFiles } from "./integrity.js"; +import { readLockfile, readLockfileText, serializeLockfile } from "./lockfile.js"; +import type { LockEntry, Lockfile } from "./types.js"; +import { createMemFs } from "./testing.js"; + +const entry = (over: Partial = {}): LockEntry => ({ + kind: "plugin", + manifest: { name: "demo", version: "1.0.0" }, + source: { type: "local", uri: "./demo" }, + integrity: "sha256-AAA", + installedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + targets: [], + ...over, +}); + +describe("lockfile", () => { + it("serialize: sorted keys + 2-space JSON", () => { + const lock: Lockfile = { + version: 1, + extensions: { zeta: entry(), alpha: entry({ manifest: { name: "alpha", version: "0.1.0" } }) }, + }; + const text = serializeLockfile(lock); + expect(text.indexOf('"alpha"')).toBeLessThan(text.indexOf('"zeta"')); + expect(text).toContain(' "version": 1'); + expect(readLockfileText(text).lockfile.extensions["alpha"]).toBeTruthy(); + }); + + it("missing file → empty; corrupt → flagged, content preserved", async () => { + const fs = createMemFs(); + expect((await readLockfile(fs, "/store/extensions.lock")).lockfile.extensions).toEqual({}); + fs.putFile("/store/extensions.lock", "{not json!!"); + const res = await readLockfile(fs, "/store/extensions.lock"); + expect(res.corrupt).toBeTruthy(); + expect(res.lockfile.extensions).toEqual({}); + }); + + it("version>1 refuses", () => { + expect(() => readLockfileText(JSON.stringify({ version: 2, extensions: {} }))).toThrow(); + }); + + it("unknown fields on entries survive a roundtrip (forward-compat)", () => { + const e = { ...entry(), futureField: { a: 1 } }; + const text = serializeLockfile({ version: 1, extensions: { demo: e } }); + const back = readLockfileText(text); + expect((back.lockfile.extensions["demo"] as Record)["futureField"]).toEqual({ a: 1 }); + }); +}); + +describe("computeIntegrity (lockfile §4 SRI)", () => { + it("hashes sorted ␣␣ lines and is stable", async () => { + const fs = createMemFs(); + fs.putFile("/pkg/plugin.json", "{}"); + fs.putFile("/pkg/a.txt", "hello"); + fs.putFile("/pkg/sub/b.txt", "world"); + const sri1 = await computeIntegrity(fs, "/pkg"); + const sri2 = await computeIntegrity(fs, "/pkg"); + expect(sri1).toBe(sri2); + expect(sri1.startsWith("sha256-")).toBe(true); + const files = await listPackageFiles(fs, "/pkg"); + expect(files.map((f) => f.path)).toEqual([...files.map((f) => f.path)].sort()); + }); + + it("changes when contents change", async () => { + const fs = createMemFs(); + fs.putFile("/pkg/a.txt", "v1"); + const a = await computeIntegrity(fs, "/pkg"); + fs.putFile("/pkg/a.txt", "v2"); + expect(await computeIntegrity(fs, "/pkg")).not.toBe(a); + }); + + it("symlink hashes the target string; dangling aborts", async () => { + const fs = createMemFs(); + fs.putFile("/pkg/a.txt", "x"); + fs.putSymlink("/pkg/link", "a.txt"); + const ok = await computeIntegrity(fs, "/pkg"); + expect(ok).toMatch(/^sha256-/); + fs.putSymlink("/pkg/dangling", "missing"); + await expect(computeIntegrity(fs, "/pkg")).rejects.toMatchObject({ kind: "trust-violation" }); + }); +}); diff --git a/packages/sdk/src/lockfile.ts b/packages/sdk/src/lockfile.ts new file mode 100644 index 0000000..231d637 --- /dev/null +++ b/packages/sdk/src/lockfile.ts @@ -0,0 +1,106 @@ +/** + * `extensions.lock` IO — spec/lockfile.md: UTF-8 JSON, two-space + * indentation, extension keys sorted lexically, atomic staged writes + * under `harness/.lock`, unknown fields preserved on rewrite. + */ + +import { FsPort } from "./ports.js"; +import type { LockEntry, Lockfile } from "./types.js"; +import { StoreError } from "./errors.js"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +export const LOCKFILE_VERSION = 1; + +export const emptyLockfile = (): Lockfile => ({ version: 1, extensions: {} }); + +export interface LockfileRead { + lockfile: Lockfile; + /** Set when the on-disk file was missing/invalid — call sites audit it. */ + corrupt?: { reason: string; raw?: string }; +} + +const isLockEntry = (v: unknown): v is LockEntry => + typeof v === "object" && + v !== null && + typeof (v as LockEntry)["kind"] === "string" && + typeof (v as LockEntry)["integrity"] === "string" && + typeof (v as LockEntry)["manifest"] === "object" && + typeof (v as LockEntry)["source"] === "object" && + typeof (v as LockEntry)["installedAt"] === "string" && + typeof (v as LockEntry)["updatedAt"] === "string" && + Array.isArray((v as LockEntry)["targets"]); + +/** + * Read + lightly validate `extensions.lock`. A missing file reads as an + * empty lockfile; an unparseable or schema-broken one reads as empty AND + * reports `corrupt` (the writer preserves the file before overwriting — + * lockfile.md §2.4). `version > 1` throws — readers refuse it (§6). + */ +export const readLockfileText = (text: string): LockfileRead => { + let doc: unknown; + try { + doc = JSON.parse(text); + } catch (e) { + return { + lockfile: emptyLockfile(), + corrupt: { reason: `invalid JSON: ${(e as Error).message}`, raw: text }, + }; + } + if (typeof doc !== "object" || doc === null || Array.isArray(doc)) + return { + lockfile: emptyLockfile(), + corrupt: { reason: "lockfile is not a JSON object", raw: text }, + }; + const rec = doc as Record; + if (typeof rec["version"] === "number" && rec["version"] > LOCKFILE_VERSION) + throw new StoreError( + "invalid", + `extensions.lock version ${rec["version"]} > supported ${LOCKFILE_VERSION}`, + { version: rec["version"] }, + ); + if (typeof rec["extensions"] !== "object" || rec["extensions"] === null) + return { + lockfile: emptyLockfile(), + corrupt: { reason: "lockfile lacks an extensions object", raw: text }, + }; + const extensions: Record = {}; + for (const [name, entry] of Object.entries(rec["extensions"])) { + if (isLockEntry(entry)) extensions[name] = entry; + } + // Unknown top-level fields ride through untouched (§5.5 forward-compat). + const { version: _v, extensions: _e, ...rest } = rec; + return { + lockfile: { ...rest, version: 1, extensions }, + }; +}; + +/** Serialize: sorted extension keys, two-space indent (§2.2). */ +export const serializeLockfile = (lockfile: Lockfile): string => { + const sorted: Record = {}; + for (const name of Object.keys(lockfile.extensions).sort()) + sorted[name] = lockfile.extensions[name]; + const { extensions: _e, ...rest } = lockfile; + return `${JSON.stringify({ ...rest, version: lockfile.version, extensions: sorted }, null, 2)}\n`; +}; + +export const readLockfile = async (fs: FsPort, path: string): Promise => { + let text: string; + try { + text = decoder.decode(await fs.readFile(path)); + } catch (e) { + if (e instanceof StoreError) throw e; + if ( + typeof e === "object" && + e !== null && + (e as { code?: string }).code === "not-found" + ) + return { lockfile: emptyLockfile() }; + throw e; + } + return readLockfileText(text); +}; + +export const encodeLockfile = (lockfile: Lockfile): Uint8Array => + encoder.encode(serializeLockfile(lockfile)); diff --git a/packages/sdk/src/manifest.test.ts b/packages/sdk/src/manifest.test.ts new file mode 100644 index 0000000..0cc8d4b --- /dev/null +++ b/packages/sdk/src/manifest.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { inspectPackage, isValidExtensionName, parseManifestText, validateManifest } from "./manifest.js"; +import { createMemFs } from "./testing.js"; + +const base = { + $schema: "https://agents.json/plugin.schema.json", + name: "demo", + version: "1.0.0", +}; + +const errorsOf = (r: { issues: { level: string }[] }) => r.issues.filter((i) => i.level === "error"); +const warningsOf = (r: { issues: { level: string }[] }) => r.issues.filter((i) => i.level === "warning"); + +describe("validateManifest", () => { + it("accepts a minimal Agent Plugins manifest", () => { + const r = validateManifest(base); + expect(r.manifest?.name).toBe("demo"); + expect(errorsOf(r)).toHaveLength(0); + }); + + it("requires $schema, name, version", () => { + for (const missing of ["$schema", "name", "version"] as const) { + const doc = { ...base }; + delete (doc as Record)[missing]; + const r = validateManifest(doc); + expect(r.manifest === undefined || errorsOf(r).length > 0).toBe(true); + } + }); + + it("namespaceVersion > 1 → warn and skip namespace", () => { + const r = validateManifest({ ...base, extensions: { "dev.anyharness": { namespaceVersion: 99 } } }); + expect(warningsOf(r).length).toBeGreaterThan(0); + expect(r.manifest?.namespace).toBeUndefined(); + }); + + it("engines.harness range is checked", () => { + const r = validateManifest({ ...base, extensions: { "dev.anyharness": { namespaceVersion: 1, engines: { harness: ">=99.0.0" } } } }); + expect(r.issues.length).toBeGreaterThan(0); + }); + + it("isValidExtensionName enforces §5.5", () => { + expect(isValidExtensionName("demo-plugin-2")).toBe(true); + expect(isValidExtensionName("demo_plugin")).toBe(false); + expect(isValidExtensionName("")).toBe(false); + expect(isValidExtensionName("a".repeat(65))).toBe(false); + expect(isValidExtensionName("bad name")).toBe(false); + expect(isValidExtensionName("../evil")).toBe(false); + }); + + it("parseManifestText surfaces invalid JSON", () => { + expect(parseManifestText("{oops").manifest).toBeUndefined(); + }); +}); + +describe("inspectPackage", () => { + it("inventories components: skills dir, mcp.json, hooks, commands/agents/rules, setup", async () => { + const fs = createMemFs(); + fs.putFile("/pkg/plugin.json", JSON.stringify(base)); + fs.putFile("/pkg/skills/helper/SKILL.md", "---\nname: helper\n---\nx"); + fs.putFile("/pkg/mcp.json", JSON.stringify({ mcpServers: { a: { command: "a" } } })); + fs.putFile("/pkg/dev.anyharness/hooks.json", JSON.stringify({ hooks: { "tool.before": [{ command: "./h.sh" }] } })); + fs.putFile("/pkg/dev.anyharness/commands/run.md", "Run."); + fs.putFile("/pkg/dev.anyharness/agents/rev.md", "Review."); + fs.putFile("/pkg/dev.anyharness/rules/r.md", "Rule."); + fs.putFile("/pkg/dev.anyharness/setup", "echo hi"); + const ins = await inspectPackage(fs, "/pkg"); + const kinds = ins.components.map((c) => c.kind); + for (const k of ["skill", "mcp", "hook", "command", "agent", "rule"]) expect(kinds).toContain(k as never); + expect(ins.hasSetupScript).toBe(true); + expect(ins.mcpServers.map((s) => s.name)).toContain("a"); + }); + + it("standalone SKILL.md dir → skill via standaloneSkill", async () => { + const fs = createMemFs(); + fs.putFile("/sk/SKILL.md", "---\nname: solo\ndescription: d\n---\nx"); + const ins = await inspectPackage(fs, "/sk"); + expect(ins.standaloneSkill?.name).toBe("solo"); + }); +}); diff --git a/packages/sdk/src/manifest.ts b/packages/sdk/src/manifest.ts new file mode 100644 index 0000000..07dd8e0 --- /dev/null +++ b/packages/sdk/src/manifest.ts @@ -0,0 +1,381 @@ +/** + * Manifest handling — spec/manifest.md: Agent Plugins 1.0 `plugin.json` + * base plus the `dev.anyharness` namespace, and the fixed-location + * component inventory (§5) used for lockfile `components[]`. + */ + +import { FsPort } from "./ports.js"; +import { basename, join } from "./path.js"; +import { parseFrontmatter } from "./frontmatter.js"; +import { satisfiesRange } from "./semver.js"; +import type { ComponentRef, ExtensionKind } from "./types.js"; + +const decoder = new TextDecoder(); + +/** Agent Plugins §5.5 name constraints (1–64, a-z0-9.-, alnum ends, no `--`/`..`). */ +const NAME_RE = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +export const isValidExtensionName = (name: string): boolean => + name.length >= 1 && name.length <= 64 && NAME_RE.test(name); + +export interface ManifestIssue { + level: "error" | "warning"; + message: string; +} + +export interface AnyharnessNamespace { + namespaceVersion: number; + capabilities: string[]; + engines?: { harness?: string }; + hooksFormat: number; + componentsFormat: number; +} + +export interface ParsedManifest { + raw: Record; + name: string; + version: string; + schemaId: string; + namespace?: AnyharnessNamespace; +} + +const KNOWN_TOP_LEVEL = new Set([ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +]); + +/** AnyHarness implementation version ranges are checked against. */ +export const SDK_VERSION = "0.1.0"; +/** dev.anyharness namespace format this build understands. */ +export const NAMESPACE_VERSION = 1; + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Validate a parsed `plugin.json` document. Returns the manifest on + * success; `issues` carries both fatal errors and report-and-ignore + * warnings (unknown top-level fields per Agent Plugins §5.2). + */ +export const validateManifest = ( + doc: unknown, +): { manifest?: ParsedManifest; issues: ManifestIssue[] } => { + const issues: ManifestIssue[] = []; + if (!isPlainObject(doc)) { + issues.push({ level: "error", message: "plugin.json is not a JSON object" }); + return { issues }; + } + for (const key of Object.keys(doc)) { + if (!KNOWN_TOP_LEVEL.has(key)) + issues.push({ level: "warning", message: `unknown top-level field ignored: ${key}` }); + } + const schemaId = doc["$schema"]; + if (typeof schemaId !== "string" || schemaId === "") + issues.push({ level: "error", message: "missing required $schema id" }); + const name = doc["name"]; + if (typeof name !== "string" || !isValidExtensionName(name)) + issues.push({ + level: "error", + message: `invalid or missing name (Agent Plugins §5.5): ${JSON.stringify(name)}`, + }); + const version = doc["version"]; + // manifest.md §7: installers SHOULD require version — ManifestRef and + // update checks are meaningless without it, so this build does. + if (typeof version !== "string" || version === "") + issues.push({ level: "error", message: "missing required version" }); + + let namespace: AnyharnessNamespace | undefined; + const extensions = doc["extensions"]; + if (extensions !== undefined) { + if (!isPlainObject(extensions)) { + issues.push({ level: "error", message: "extensions is not an object" }); + } else { + const ns = extensions["dev.anyharness"]; + if (ns !== undefined) { + if (!isPlainObject(ns)) { + issues.push({ level: "error", message: "dev.anyharness is not an object" }); + } else { + const nv = ns["namespaceVersion"]; + if (typeof nv !== "number" || !Number.isInteger(nv)) { + issues.push({ + level: "warning", + message: + "dev.anyharness.namespaceVersion missing/non-integer — namespace skipped per manifest.md §4", + }); + } else if (nv > NAMESPACE_VERSION) { + issues.push({ + level: "warning", + message: `dev.anyharness.namespaceVersion ${nv} > supported ${NAMESPACE_VERSION} — namespace skipped`, + }); + } else { + const caps = ns["capabilities"]; + const engines = ns["engines"]; + namespace = { + namespaceVersion: nv, + capabilities: Array.isArray(caps) + ? caps.filter((c): c is string => typeof c === "string") + : [], + engines: isPlainObject(engines) + ? { harness: typeof engines["harness"] === "string" ? engines["harness"] : undefined } + : undefined, + hooksFormat: typeof ns["hooksFormat"] === "number" ? ns["hooksFormat"] : 1, + componentsFormat: + typeof ns["componentsFormat"] === "number" ? ns["componentsFormat"] : 1, + }; + if (namespace.engines?.harness !== undefined) { + if (!satisfiesRange(SDK_VERSION, namespace.engines.harness)) + issues.push({ + level: "error", + message: `engines.harness "${namespace.engines.harness}" excludes implementation ${SDK_VERSION}`, + }); + } + for (const key of Object.keys(ns)) { + if ( + !["namespaceVersion", "capabilities", "engines", "hooksFormat", "componentsFormat"].includes(key) + ) + issues.push({ + level: "warning", + message: `unknown dev.anyharness field ignored: ${key}`, + }); + } + } + } + } + } + } + + if (issues.some((i) => i.level === "error")) return { issues }; + return { + manifest: { + raw: doc, + name: name as string, + version: version as string, + schemaId: schemaId as string, + namespace, + }, + issues, + }; +}; + +export const parseManifestText = ( + text: string, +): { manifest?: ParsedManifest; issues: ManifestIssue[] } => { + let doc: unknown; + try { + doc = JSON.parse(text); + } catch (e) { + return { + issues: [ + { level: "error", message: `plugin.json is not valid JSON: ${(e as Error).message}` }, + ], + }; + } + return validateManifest(doc); +}; + +/* ------------------------------------------------------------------ */ +/* Package inspection — fixed-location component inventory (§5) */ +/* ------------------------------------------------------------------ */ + +export interface HookDeclaration { + event: string; + command: string; + args: string[]; + timeout?: number; +} + +export interface McpServerDecl { + name: string; + config: Record; +} + +export interface PackageInspection { + /** Parsed plugin.json when present and valid; bare skills have none. */ + manifest?: ParsedManifest; + issues: ManifestIssue[]; + components: ComponentRef[]; + hooks: HookDeclaration[]; + mcpServers: McpServerDecl[]; + /** Root-level SKILL.md → standalone skill package (kind `skill`). */ + standaloneSkill?: { name: string; version: string; description?: string }; + hasSetupScript: boolean; +} + +const readIfExists = async (fs: FsPort, path: string): Promise => { + try { + return decoder.decode(await fs.readFile(path)); + } catch { + return null; + } +}; + +const markdownNames = async ( + fs: FsPort, + dir: string, + kind: ExtensionKind, +): Promise => { + const out: ComponentRef[] = []; + let entries; + try { + entries = await fs.readDir(dir); + } catch { + return out; + } + for (const entry of entries) { + if (entry.type !== "file" || !entry.name.endsWith(".md")) continue; + const stem = basename(entry.name).replace(/\.md$/, ""); + const text = await readIfExists(fs, join(dir, entry.name)); + const fm = text === null ? {} : parseFrontmatter(text).frontmatter; + const fmName = typeof fm["name"] === "string" ? fm["name"] : undefined; + out.push({ kind, name: fmName ?? stem }); + } + return out; +}; + +/** Parse `dev.anyharness/hooks.json` (hooksFormat 1). */ +const readHooks = async ( + fs: FsPort, + path: string, +): Promise<{ hooks: HookDeclaration[]; error?: string }> => { + const text = await readIfExists(fs, path); + if (text === null) return { hooks: [] }; + let doc: unknown; + try { + doc = JSON.parse(text); + } catch (e) { + return { hooks: [], error: `hooks.json invalid JSON: ${(e as Error).message}` }; + } + if (!isPlainObject(doc) || !isPlainObject(doc["hooks"])) + return { hooks: [], error: "hooks.json lacks a \"hooks\" object" }; + const out: HookDeclaration[] = []; + for (const [event, entries] of Object.entries(doc["hooks"])) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!isPlainObject(entry) || typeof entry["command"] !== "string") continue; + out.push({ + event, + command: entry["command"], + args: Array.isArray(entry["args"]) + ? entry["args"].filter((a): a is string => typeof a === "string") + : [], + timeout: typeof entry["timeout"] === "number" ? entry["timeout"] : undefined, + }); + } + } + return { hooks: out }; +}; + +/** Parse a package-root `mcp.json` (Agent Plugins §7.2 shape). */ +const readPackageMcp = async ( + fs: FsPort, + path: string, +): Promise<{ servers: McpServerDecl[]; error?: string }> => { + const text = await readIfExists(fs, path); + if (text === null) return { servers: [] }; + let doc: unknown; + try { + doc = JSON.parse(text); + } catch (e) { + return { servers: [], error: `mcp.json invalid JSON: ${(e as Error).message}` }; + } + const servers: McpServerDecl[] = []; + if (isPlainObject(doc) && isPlainObject(doc["mcpServers"])) { + for (const [name, config] of Object.entries(doc["mcpServers"])) { + if (isPlainObject(config)) servers.push({ name, config }); + } + } + return { servers }; +}; + +/** + * Inventory a staged/installed package directory: manifest, components at + * their spec-fixed locations, hooks, MCP declarations, standalone-skill + * detection. + */ +export const inspectPackage = async ( + fs: FsPort, + pkgDir: string, +): Promise => { + const issues: ManifestIssue[] = []; + const components: ComponentRef[] = []; + + const manifestText = await readIfExists(fs, join(pkgDir, "plugin.json")); + let manifest: ParsedManifest | undefined; + if (manifestText !== null) { + const parsed = parseManifestText(manifestText); + manifest = parsed.manifest; + issues.push(...parsed.issues); + } + + // Standalone skill: root SKILL.md without a plugin.json. + let standaloneSkill: PackageInspection["standaloneSkill"]; + const skillText = await readIfExists(fs, join(pkgDir, "SKILL.md")); + if (manifest === undefined && skillText !== null) { + const { frontmatter } = parseFrontmatter(skillText); + const fm = frontmatter; + const fmName = typeof fm["name"] === "string" ? fm["name"] : undefined; + const fmVersion = typeof fm["version"] === "string" ? fm["version"] : undefined; + const fmDesc = typeof fm["description"] === "string" ? fm["description"] : undefined; + const name = fmName ?? basename(pkgDir); + if (!isValidExtensionName(name)) + issues.push({ + level: "error", + message: `standalone skill name "${name}" violates Agent Plugins §5.5`, + }); + else standaloneSkill = { name, version: fmVersion ?? "0.0.0", description: fmDesc }; + } + + // skills// with SKILL.md → skill components. + const skillsDir = join(pkgDir, "skills"); + try { + for (const entry of await fs.readDir(skillsDir)) { + if (entry.type !== "directory" && entry.type !== "symlink") continue; + const skillMd = await fs.stat(join(skillsDir, entry.name, "SKILL.md")); + if (skillMd !== null) components.push({ kind: "skill", name: entry.name }); + } + } catch { + /* absent skills/ is not an error (Agent Plugins §6.2) */ + } + if (standaloneSkill !== undefined) + components.push({ kind: "skill", name: standaloneSkill.name }); + + // mcp.json → one mcp component per declared server key. + const mcp = await readPackageMcp(fs, join(pkgDir, "mcp.json")); + if (mcp.error !== undefined) issues.push({ level: "warning", message: mcp.error }); + for (const s of mcp.servers) components.push({ kind: "mcp", name: s.name }); + + // dev.anyharness/ behavioral components. + const nsDir = join(pkgDir, "dev.anyharness"); + const hooks = await readHooks(fs, join(nsDir, "hooks.json")); + if (hooks.error !== undefined) issues.push({ level: "warning", message: hooks.error }); + for (const h of hooks.hooks) + components.push({ kind: "hook", name: h.event }); + for (const [dir, kind] of [ + ["commands", "command"], + ["agents", "agent"], + ["rules", "rule"], + ] as const) { + for (const c of await markdownNames(fs, join(nsDir, dir), kind)) + components.push(c); + } + const hasSetupScript = + (await fs.stat(join(nsDir, "setup")))?.type === "file"; + + return { + manifest, + issues, + components, + hooks: hooks.hooks, + mcpServers: mcp.servers, + standaloneSkill, + hasSetupScript, + }; +}; diff --git a/packages/sdk/src/mcpmerge.ts b/packages/sdk/src/mcpmerge.ts new file mode 100644 index 0000000..980a594 --- /dev/null +++ b/packages/sdk/src/mcpmerge.ts @@ -0,0 +1,130 @@ +/** + * `~/.agents/mcp.json` merge discipline — store-layout.md §5.2. + * The shared file is ours to merge, not own: we add/replace only the + * `mcpServers` member names our extensions manage (recorded in + * `extensions.lock` `components[]`), preserve every other member name and + * every other top-level field, and never create a non-`mcpServers` + * top-level shape. + */ + +import { FsPort } from "./ports.js"; +import { atomicWriteFile } from "./atomic.js"; +import { join } from "./path.js"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +export interface RootMcpRead { + /** Full parsed document when present and an object; null when absent. */ + doc: Record | null; + /** Server map; null when the file is absent or a foreign dialect. */ + mcpServers: Record | null; + /** Present when the file exists but must not be modified (§5.2.4). */ + foreignDialect?: string; +} + +export const readRootMcp = async (fs: FsPort, path: string): Promise => { + let text: string; + try { + text = decoder.decode(await fs.readFile(path)); + } catch { + return { doc: null, mcpServers: {} }; + } + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return { doc: null, mcpServers: null, foreignDialect: "mcp.json is not valid JSON" }; + } + if (!isPlainObject(doc) || !isPlainObject(doc["mcpServers"])) + return { + doc: isPlainObject(doc) ? doc : null, + mcpServers: null, + foreignDialect: "mcp.json lacks an object-valued mcpServers member", + }; + return { doc, mcpServers: doc["mcpServers"] }; +}; + +/** Recursively expand ${PLUGIN_ROOT}/${PLUGIN_DATA} in config values (§9.2). */ +const expandPlaceholders = ( + value: unknown, + pluginRoot: string, + pluginData: string, +): unknown => { + if (typeof value === "string") + return value.replaceAll("${PLUGIN_ROOT}", pluginRoot).replaceAll("${PLUGIN_DATA}", pluginData); + if (Array.isArray(value)) + return value.map((v) => expandPlaceholders(v, pluginRoot, pluginData)); + if (isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) + out[k] = expandPlaceholders(v, pluginRoot, pluginData); + return out; + } + return value; +}; + +export interface McpMergeResult { + merged: string[]; + skippedForeignDialect: boolean; +} + +/** + * Merge a package's declared MCP servers into the root `mcp.json`. + * `servers` are the package-side declarations; placeholders expand to the + * installed package/data dirs. Foreign member names and top-level fields + * are preserved untouched. + */ +export const mergeMcpServers = async ( + fs: FsPort, + mcpPath: string, + servers: { name: string; config: Record }[], + pluginRoot: string, + pluginData: string, +): Promise => { + if (servers.length === 0) return { merged: [], skippedForeignDialect: false }; + const read = await readRootMcp(fs, mcpPath); + if (read.foreignDialect !== undefined) + return { merged: [], skippedForeignDialect: true }; + const doc: Record = { ...(read.doc ?? {}) }; + const mcpServers = { ...(read.mcpServers ?? {}) }; + const merged: string[] = []; + for (const { name, config } of servers) { + mcpServers[name] = expandPlaceholders(config, pluginRoot, pluginData); + merged.push(name); + } + doc["mcpServers"] = mcpServers; + await atomicWriteFile(fs, mcpPath, encoder.encode(`${JSON.stringify(doc, null, 2)}\n`)); + return { merged, skippedForeignDialect: false }; +}; + +/** Remove our managed member names from root mcp.json; preserve the rest. */ +export const removeMcpServers = async ( + fs: FsPort, + mcpPath: string, + names: string[], +): Promise => { + if (names.length === 0) return { merged: [], skippedForeignDialect: false }; + const read = await readRootMcp(fs, mcpPath); + if (read.doc === null || read.mcpServers === null) + return { merged: [], skippedForeignDialect: read.foreignDialect !== undefined }; + const mcpServers = { ...read.mcpServers }; + const removed: string[] = []; + for (const name of names) { + if (name in mcpServers) { + delete mcpServers[name]; + removed.push(name); + } + } + if (removed.length > 0) { + const doc = { ...read.doc, mcpServers }; + await atomicWriteFile(fs, mcpPath, encoder.encode(`${JSON.stringify(doc, null, 2)}\n`)); + } + return { merged: removed, skippedForeignDialect: false }; +}; + +export const rootMcpPathFor = (agentsRoot: string): string => + join(agentsRoot, "mcp.json"); diff --git a/packages/sdk/src/mutex.ts b/packages/sdk/src/mutex.ts new file mode 100644 index 0000000..a7f6dfe --- /dev/null +++ b/packages/sdk/src/mutex.ts @@ -0,0 +1,70 @@ +/** + * Advisory write mutex — `harness/.lock` per store-layout.md §7.2. + * Mutual exclusion comes from `FsPort.createExclusive` (O_EXCL create); + * a lock older than `staleMs` is presumed abandoned and broken (the + * spec leaves the stale-breaking mechanism implementation-defined — + * isomorphic runtimes cannot inspect holder pids, so age is the proof). + */ + +import { FsPort } from "./ports.js"; +import { dirname, join } from "./path.js"; +import { conflict } from "./errors.js"; + +export interface MutexOptions { + /** ms before an unreleased lock is treated as stale. Default 30_000. */ + staleMs?: number; + /** Total ms to keep retrying before failing with `conflict`. Default 15_000. */ + timeoutMs?: number; + /** Poll interval, ms. Default 40. */ + pollMs?: number; +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Acquire `harness/.lock`, run `fn`, release — always, even on throw. + * Reentrant within one Store call stack: `fn` receives the same guard so + * nested helpers share the outer hold (a single-threaded runtime means + * plain synchronous reentry is the only nesting we get). + */ +export const withLock = async ( + fs: FsPort, + lockPath: string, + options: MutexOptions, + fn: () => Promise, +): Promise => { + const staleMs = options.staleMs ?? 30_000; + const timeoutMs = options.timeoutMs ?? 15_000; + const pollMs = options.pollMs ?? 40; + const deadline = Date.now() + timeoutMs; + const token = new TextEncoder().encode( + JSON.stringify({ ts: new Date().toISOString(), nonce: Math.random().toString(36).slice(2) }), + ); + + // The lock lives inside harness/, which a fresh store may not have yet. + await fs.mkdir(dirname(lockPath)); + + for (;;) { + if (await fs.createExclusive(lockPath, token)) break; + // Lock held — check for staleness, else wait. + const st = await fs.stat(lockPath); + const age = st === null ? 0 : Date.now() - st.mtimeMs; + if (st !== null && age > staleMs) { + await fs.remove(lockPath).catch(() => undefined); + continue; // stale lock broken + } + if (Date.now() > deadline) + throw conflict(`timed out acquiring ${lockPath}`); + await sleep(pollMs); + } + + try { + return await fn(); + } finally { + await fs.remove(lockPath).catch(() => undefined); + } +}; + +export const lockPathFor = (harnessDir: string): string => + join(harnessDir, ".lock"); diff --git a/packages/sdk/src/path.test.ts b/packages/sdk/src/path.test.ts new file mode 100644 index 0000000..54fea24 --- /dev/null +++ b/packages/sdk/src/path.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { basename, compareUtf8, dirname, isInside, join, normalize, resolveInside } from "./path.js"; + +describe("path utils", () => { + it("normalizes separators, dots, trailing slash", () => { + expect(normalize("/a//b/./c/")).toBe("/a/b/c"); + expect(normalize("a/b/../c")).toBe("a/c"); + expect(normalize("/")).toBe("/"); + }); + + it("join concatenates + normalizes", () => { + expect(join("/root", "a", "b")).toBe("/root/a/b"); + expect(join("/root/", "/a")).toBe("/root/a"); + }); + + it("isInside(root, path) boundaries", () => { + expect(isInside("/a", "/a/b")).toBe(true); + expect(isInside("/a/b", "/a/b/c")).toBe(true); + expect(isInside("/a", "/ab")).toBe(false); + expect(isInside("/a", "/a/../b")).toBe(false); + }); + + it("resolveInside rejects escapes", () => { + expect(resolveInside("/store", "pkg/a")).toBe("/store/pkg/a"); + expect(resolveInside("/store", "../evil")).toBeNull(); + expect(resolveInside("/store", "/abs")).toBeNull(); + }); + + it("basename/dirname", () => { + expect(basename("/a/b/c.txt")).toBe("c.txt"); + expect(dirname("/a/b/c.txt")).toBe("/a/b"); + }); + + it("compareUtf8 orders by byte value", () => { + expect(compareUtf8("a", "b") < 0).toBe(true); + expect(compareUtf8("Z", "a") < 0).toBe(true); // byte order: Z(90) < a(97) + expect(compareUtf8("a/b", "ab")).not.toBe(0); + const sorted = ["zeta", "Alpha", "a/b"].sort(compareUtf8); + expect(sorted[0]).toBe("Alpha"); + }); +}); diff --git a/packages/sdk/src/path.ts b/packages/sdk/src/path.ts new file mode 100644 index 0000000..4e0d1cb --- /dev/null +++ b/packages/sdk/src/path.ts @@ -0,0 +1,80 @@ +/** + * POSIX logical-path helpers. Store paths are UTF-8, `/`-separated logical + * paths (store-layout.md §6.1); node path is unavailable isomorphically. + */ + +/** Join path segments with `/`, collapsing redundant separators. */ +export const join = (...parts: string[]): string => { + const raw = parts.filter((p) => p.length > 0).join("/"); + return raw.replace(/\/+/g, "/"); +}; + +/** + * Normalize a `/`-separated path: resolve `.` and `..` segments + * lexically (no symlink resolution — that is `resolveInside`'s job). + * Keeps a leading `/` when present; never emits a trailing `/` (except + * for the root itself, which returns "/"). + */ +export const normalize = (path: string): string => { + const absolute = path.startsWith("/"); + const out: string[] = []; + for (const seg of path.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") { + if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); + else if (!absolute) out.push(".."); + // ".." above a filesystem root clamps to root. + continue; + } + out.push(seg); + } + const joined = out.join("/"); + return absolute ? `/${joined}` : joined === "" ? "." : joined; +}; + +/** True when `child` equals or sits under `parent` after normalization. */ +export const isInside = (parent: string, child: string): boolean => { + const p = normalize(parent); + const c = normalize(child); + return c === p || c.startsWith(p === "/" ? "/" : `${p}/`); +}; + +/** + * Resolve a package-relative reference (`./x/y`, or a bare relative path) + * inside `root`. Returns the normalized absolute path, or `null` when the + * reference escapes the root (spec/store-layout.md §6.3 containment). + */ +export const resolveInside = ( + root: string, + rel: string, +): string | null => { + if (rel.startsWith("/")) return null; // absolute refs are never inside + const resolved = normalize(join(root, rel)); + return isInside(root, resolved) ? resolved : null; +}; + +export const basename = (path: string): string => { + const trimmed = path.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + return idx < 0 ? trimmed : trimmed.slice(idx + 1); +}; + +export const dirname = (path: string): string => { + const trimmed = path.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + if (idx < 0) return "."; + if (idx === 0) return "/"; + return trimmed.slice(0, idx); +}; + +/** Compare two strings by UTF-8 byte order (lockfile.md §4 file sorting). */ +const byteEncoder = new TextEncoder(); +export const compareUtf8 = (a: string, b: string): number => { + const ba = byteEncoder.encode(a); + const bb = byteEncoder.encode(b); + const n = Math.min(ba.length, bb.length); + for (let i = 0; i < n; i++) { + if (ba[i] !== bb[i]) return ba[i] - bb[i]; + } + return ba.length - bb.length; +}; diff --git a/packages/sdk/src/policy.test.ts b/packages/sdk/src/policy.test.ts new file mode 100644 index 0000000..79081ca --- /dev/null +++ b/packages/sdk/src/policy.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_POLICY, + loadPolicy, + parsePolicyText, + resolveExecDecision, + sourceAllowed, + sourceAllowlisted, +} from "./policy.js"; +import { resolveSource } from "./sources.js"; +import { createMemFs } from "./testing.js"; + +describe("trust policy", () => { + it("defaults match spec §4.1 — ask for interactive exec, deny nonInteractive, empty lists", () => { + const p = DEFAULT_POLICY; + expect(p.exec.setup).toBe("ask"); + expect(p.exec.hooks).toBe("ask"); + expect(p.exec.mcp).toBe("ask"); + expect(p.exec.skillScripts).toBe("ask"); + expect(p.exec.nonInteractive).toBe("deny"); + expect(p.sources.allow).toEqual([]); + expect(p.sources.deny).toEqual([]); + }); + + it("deny list beats allow list; empty allow = unrestricted", () => { + const p = { + ...DEFAULT_POLICY, + sources: { allow: ["trusted/*"], deny: ["evil/**"] }, + }; + expect(sourceAllowed(p, resolveSource("github:evil/x"))).toBe(false); + expect(sourceAllowed(p, resolveSource("gh:trusted/pkg"))).toBe(true); + expect(sourceAllowed(DEFAULT_POLICY, resolveSource("github:anything/ok"))).toBe(true); + }); + + it("agent ask → deny unless source allowlisted; user ask → ask", () => { + const src = resolveSource("github:some/pkg"); + const p = { ...DEFAULT_POLICY, sources: { allow: ["some/*"], deny: [] } }; + expect(resolveExecDecision(DEFAULT_POLICY, "setup", "user", src)).toBe("ask"); + expect(resolveExecDecision(DEFAULT_POLICY, "setup", "agent", src)).toBe("deny"); + expect(resolveExecDecision(p, "setup", "agent", src)).toBe("ask"); + expect(sourceAllowlisted(p, src)).toBe(true); + }); + + it("non-user + nonInteractive=deny resolves ask → deny for unattended", () => { + expect(resolveExecDecision(DEFAULT_POLICY, "setup", "daemon", resolveSource("local:/tmp/x"))) + .toBe("deny"); + }); + + it("parses config.toml [policy] + reports syntax errors", async () => { + const fs = createMemFs(); + const read = await loadPolicy(fs, "/store/config.toml"); + expect(read.policy).toEqual(DEFAULT_POLICY); + const r = parsePolicyText(` +[policy.exec] +hooks = "deny" +nonInteractive = "allow" +[policy.sources] +allow = ["ok/**"] +`); + expect(r.policy.exec.hooks).toBe("deny"); + expect(r.policy.exec.nonInteractive).toBe("allow"); + expect(r.policy.sources.allow).toEqual(["ok/**"]); + const bad = parsePolicyText("[unterminated"); + expect(bad.corrupt).toBeTruthy(); + }); +}); diff --git a/packages/sdk/src/policy.ts b/packages/sdk/src/policy.ts new file mode 100644 index 0000000..aee8a59 --- /dev/null +++ b/packages/sdk/src/policy.ts @@ -0,0 +1,131 @@ +/** + * Trust policy — spec/trust.md §4. Reads `harness/config.toml`'s + * `[policy]` table; ships the spec's required defaults; evaluates exec + * decisions (deny/ask/allow) and source allowlists. + */ + +import { parseToml } from "./toml.js"; +import { matchesAnyGlob } from "./glob.js"; +import type { Actor, ExecClass, PolicyDecision, SourceRef, TrustPolicy } from "./types.js"; +import { FsPort } from "./ports.js"; + +const POLICY_DECISIONS = new Set(["deny", "ask", "allow"]); + +export const DEFAULT_POLICY: TrustPolicy = { + exec: { + setup: "ask", + hooks: "ask", + mcp: "ask", + skillScripts: "ask", + nonInteractive: "deny", + }, + sources: { allow: [], deny: [] }, +}; + +const readDecision = (v: unknown, fallback: PolicyDecision): PolicyDecision => + typeof v === "string" && POLICY_DECISIONS.has(v as PolicyDecision) + ? (v as PolicyDecision) + : fallback; + +const readStringArray = (v: unknown): string[] => + Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; + +export interface PolicyRead { + policy: TrustPolicy; + /** Set when config.toml existed but failed to parse. */ + corrupt?: string; +} + +export const parsePolicyText = (text: string): PolicyRead => { + let doc: Record; + try { + doc = parseToml(text); + } catch (e) { + return { policy: DEFAULT_POLICY, corrupt: (e as Error).message }; + } + const policyTable = + typeof doc["policy"] === "object" && doc["policy"] !== null + ? (doc["policy"] as Record) + : {}; + const execTable = + typeof policyTable["exec"] === "object" && policyTable["exec"] !== null + ? (policyTable["exec"] as Record) + : {}; + const sourcesTable = + typeof policyTable["sources"] === "object" && policyTable["sources"] !== null + ? (policyTable["sources"] as Record) + : {}; + return { + policy: { + exec: { + setup: readDecision(execTable["setup"], DEFAULT_POLICY.exec.setup), + hooks: readDecision(execTable["hooks"], DEFAULT_POLICY.exec.hooks), + mcp: readDecision(execTable["mcp"], DEFAULT_POLICY.exec.mcp), + skillScripts: readDecision(execTable["skillScripts"], DEFAULT_POLICY.exec.skillScripts), + nonInteractive: + execTable["nonInteractive"] === "allow" ? "allow" : "deny", + }, + sources: { + allow: readStringArray(sourcesTable["allow"]), + deny: readStringArray(sourcesTable["deny"]), + }, + }, + }; +}; + +export const loadPolicy = async (fs: FsPort, configPath: string): Promise => { + try { + const text = new TextDecoder().decode(await fs.readFile(configPath)); + return parsePolicyText(text); + } catch (e) { + if (typeof e === "object" && e !== null && (e as { code?: string }).code === "not-found") + return { policy: DEFAULT_POLICY }; + throw e; + } +}; + +/* ------------------------------------------------------------------ */ +/* Evaluation */ +/* ------------------------------------------------------------------ */ + +/** + * Does policy allow installing from this source at all? + * `sources.deny` is checked first; a non-empty `sources.allow` must match. + * The match target is the source's canonical URI plus, for git/github, + * the resolved clone URL — so allowlists can pin either form. + */ +export const sourceAllowed = (policy: TrustPolicy, source: SourceRef): boolean => { + const candidates = [source.uri]; + if (source.type === "github") candidates.push(`https://github.com/${source.uri}.git`); + if (matchesAnyGlob(policy.sources.deny, source.uri)) return false; + if (candidates.slice(1).some((c) => matchesAnyGlob(policy.sources.deny, c))) return false; + if (policy.sources.allow.length === 0) return true; + return candidates.some((c) => matchesAnyGlob(policy.sources.allow, c)); +}; + +/** + * Whether `source` is on the allowlist specifically — trust.md §4.2: only + * allowlisted provenance lets `ask` survive an agent actor. + */ +export const sourceAllowlisted = (policy: TrustPolicy, source: SourceRef): boolean => + policy.sources.allow.length > 0 && sourceAllowed(policy, source); + +/** + * Resolve an exec-class decision for an actor (trust.md §4.1/§4.2). + * `ask` → `exec.nonInteractive` when the actor cannot answer, or when the + * actor is an agent and the source is not allowlisted. + */ +export const resolveExecDecision = ( + policy: TrustPolicy, + execClass: ExecClass, + actor: Actor, + source?: SourceRef, +): PolicyDecision => { + const decision = policy.exec[execClass]; + if (decision !== "ask") return decision; + if (actor === "user") return "ask"; + // §4.2: an agent's `ask` survives only for allowlisted provenance. + if (actor === "agent") + return source !== undefined && sourceAllowlisted(policy, source) ? "ask" : "deny"; + return policy.exec.nonInteractive; +}; diff --git a/packages/sdk/src/ports.ts b/packages/sdk/src/ports.ts new file mode 100644 index 0000000..c12bbe9 --- /dev/null +++ b/packages/sdk/src/ports.ts @@ -0,0 +1,150 @@ +/** + * Environment ports — the only way `packages/sdk` touches the outside. + * The isomorphic rule (AGENTS.md §2) bans node builtins imports here, so every + * filesystem/process interaction goes through a host-injected port. + * `packages/cli` owns the node-backed implementations; tests use the + * in-memory fakes exported from `./testing`. + */ + +export type FsEntryType = "file" | "directory" | "symlink" | "other"; + +export interface FsStat { + type: FsEntryType; + /** Byte length for regular files; 0 otherwise. */ + size: number; + /** Modification time, ms since epoch. 0 when the port cannot report it. */ + mtimeMs: number; +} + +export interface FsDirent { + name: string; + type: FsEntryType; +} + +export class FsError extends Error { + readonly code: "not-found" | "exists" | "not-directory" | "is-directory" | "not-empty" | "io"; + readonly path: string; + constructor(code: FsError["code"], path: string, message?: string) { + super(message ?? `${code}: ${path}`); + this.name = "FsError"; + this.code = code; + this.path = path; + } +} + +/** + * Ambient cross-runtime globals this package relies on + * (TextEncoder/TextDecoder/URL/setTimeout) — present in node ≥11, every + * browser, and every worker runtime. Declared here so the package needs + * neither node builtin imports nor @types/node. + */ +declare global { + class TextEncoder { + encode(input?: string): Uint8Array; + } + class TextDecoder { + constructor(label?: string, options?: { fatal?: boolean; ignoreBOM?: boolean }); + decode(input?: Uint8Array): string; + } + class URL { + constructor(input: string, base?: string); + protocol: string; + hostname: string; + host: string; + pathname: string; + search: string; + hash: string; + } + function setTimeout(handler: () => void, timeout?: number): number; + function clearTimeout(handle: number): void; +} + +/** + * Minimal filesystem surface the store needs. All paths are absolute, + * `/`-separated logical paths; non-POSIX ports translate internally. + * + * Contracts the port MUST honor: + * - `rename` is atomic for same-directory moves (the store's atomic-write + * protocol — store-layout.md §7.1 — stages a sibling temp then renames). + * - `createExclusive` creates the file only when it does not exist + * (O_EXCL semantics) and returns whether it won; this is the `.lock` + * mutex primitive (store-layout.md §7.2). + * - `stat` does NOT follow symlinks (lstat semantics) — integrity and + * containment depend on seeing the symlink itself. + * - `appendFile` appends atomically where the platform allows + * (O_APPEND), so concurrent audit writers never interleave mid-line. + */ +export interface FsPort { + readFile(path: string): Promise; + writeFile(path: string, data: Uint8Array): Promise; + /** mkdir -p: creates missing parents; succeeds when already present. */ + mkdir(path: string): Promise; + rename(from: string, to: string): Promise; + /** Recursive removal; missing paths succeed silently. */ + remove(path: string, opts?: { recursive?: boolean }): Promise; + stat(path: string): Promise; + readDir(path: string): Promise; + /** Target string of a symlink (raw, possibly relative). */ + readlink(path: string): Promise; + createExclusive(path: string, data: Uint8Array): Promise; + appendFile(path: string, data: Uint8Array): Promise; + /** + * Optional `ln -s` primitive. Absent ports dereference on copy (with a + * containment check); present ports preserve link identity so package + * trees keep their symlink semantics through installs and + * materialization. + */ + symlink?(target: string, path: string): Promise; +} + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +export interface ExecOptions { + cwd?: string; + env?: Record; + /** Bytes delivered to the process's stdin. */ + stdin?: string | Uint8Array; + timeoutMs?: number; +} + +/** + * Process execution — command discipline follows Agent Plugins §7.2.1: + * `command` is one executable token, `args` are passed separately, + * never a shell string. Hosts without processes (browser bundles) + * inject no ExecPort; call sites degrade via the trust policy, not + * branches on the environment. + */ +export interface ExecPort { + run( + command: string, + args?: string[], + opts?: ExecOptions, + ): Promise; +} + +/** + * Optional MCP passthrough port. The v0 sdk cannot drive MCP servers from + * `ExecPort` alone (a one-shot `run` cannot hold an MCP session open), so + * `tools.call` answers `capability-unsupported` / `transport-unavailable` + * until a host injects one. + */ +export interface McpPort { + call( + server: string, + tool: string, + args: Record, + meta?: Record, + ): Promise>; +} + +/** Pinned shape — W9 (cli) implements these against node. */ +export interface StorePorts { + fs: FsPort; + exec: ExecPort; + /** Optional; absence is declared to bridge clients via the `mcp` slot. */ + mcp?: McpPort; +} diff --git a/packages/sdk/src/semver.test.ts b/packages/sdk/src/semver.test.ts new file mode 100644 index 0000000..63b875d --- /dev/null +++ b/packages/sdk/src/semver.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { compareVersions, parseVersion, satisfiesRange } from "./semver.js"; + +describe("semver subset", () => { + it("parseVersion", () => { + expect(parseVersion("1.2.3")).toMatchObject({ major: 1, minor: 2, patch: 3 }); + expect(parseVersion("v1.2")).toBeTruthy(); + expect(parseVersion("bad")).toBeNull(); + }); + + it("compareVersions", () => { + const v = (s: string) => { + const p = parseVersion(s); + expect(p).toBeTruthy(); + return p!; + }; + expect(compareVersions(v("1.0.0"), v("1.0.1")) < 0).toBe(true); + expect(compareVersions(v("2.0.0"), v("1.9.9")) > 0).toBe(true); + expect(compareVersions(v("1.0.0"), v("1.0.0"))).toBe(0); + expect(compareVersions(v("1.0.0-alpha"), v("1.0.0")) < 0).toBe(true); + }); + + it("ranges", () => { + expect(satisfiesRange("1.2.3", "*")).toBe(true); + expect(satisfiesRange("1.2.3", "^1.0.0")).toBe(true); + expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); + expect(satisfiesRange("1.2.3", "~1.2.0")).toBe(true); + expect(satisfiesRange("1.3.0", "~1.2.0")).toBe(false); + expect(satisfiesRange("1.2.3", "1.2.x")).toBe(true); + expect(satisfiesRange("1.2.3", ">=1.0.0 <2.0.0")).toBe(true); + expect(satisfiesRange("1.2.3", "1.0.0 - 2.0.0")).toBe(true); + expect(satisfiesRange("1.2.3", "2.x || 1.x")).toBe(true); + expect(satisfiesRange("1.2.3", "=1.2.3")).toBe(true); + }); +}); diff --git a/packages/sdk/src/semver.ts b/packages/sdk/src/semver.ts new file mode 100644 index 0000000..80253bd --- /dev/null +++ b/packages/sdk/src/semver.ts @@ -0,0 +1,179 @@ +/** + * Minimal SemVer compare + range satisfaction for `engines.harness` + * (manifest.md §8.4: npm range syntax against the implementation version). + * Covers the ranges a manifest realistically declares: `*`, exact, + * `>`/`>=`/`<`/`<=`/`=` comparators, `^`, `~`, partial `x` wildcards, + * space-separated AND sets, `||` OR groups. Unsupported syntax fails + * closed (unsatisfied) rather than silently passing. + */ + +export interface SemVer { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +const VERSION_RE = + /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +export const parseVersion = (raw: string): SemVer | null => { + const m = raw.trim().match(VERSION_RE); + if (!m) return null; + return { + major: parseInt(m[1], 10), + minor: m[2] === undefined ? 0 : parseInt(m[2], 10), + patch: m[3] === undefined ? 0 : parseInt(m[3], 10), + prerelease: m[4] ? m[4].split(".") : [], + }; +}; + +/** Numeric identifier comparison; prerelease < release; per semver.org §11. */ +export const compareVersions = (a: SemVer, b: SemVer): number => { + for (const key of ["major", "minor", "patch"] as const) { + if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1; + } + const ap = a.prerelease; + const bp = b.prerelease; + if (ap.length === 0 && bp.length === 0) return 0; + if (ap.length === 0) return 1; + if (bp.length === 0) return -1; + for (let i = 0; i < Math.max(ap.length, bp.length); i++) { + const x = ap[i]; + const y = bp[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const xn = /^\d+$/.test(x); + const yn = /^\d+$/.test(y); + if (xn && yn) { + const diff = parseInt(x, 10) - parseInt(y, 10); + if (diff !== 0) return diff < 0 ? -1 : 1; + } else if (xn !== yn) { + return xn ? -1 : 1; + } else if (x !== y) { + return x < y ? -1 : 1; + } + } + return 0; +}; + +interface Comparator { + op: ">=" | "<=" | ">" | "<" | "="; + version: SemVer; +} + +const PARTIAL_RE = /^v?(\d+|x|\*)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?$/i; + +const parseComparator = (raw: string): Comparator | null => { + const m = raw.match(/^(>=|<=|>|<|=)?\s*(.+)$/); + if (!m) return null; + const op = (m[1] ?? "=") as Comparator["op"]; + const v = parseVersion(m[2]); + if (!v) return null; + return { op, version: v }; +}; + +/** Expand `^` / `~` / partials into comparator sets. */ +const expandTerm = (term: string): Comparator[] | null => { + if (term === "*" || term === "x" || term === "") return []; + const caret = term.match(/^\^(.+)$/); + if (caret) { + const v = parseVersion(caret[1]); + if (!v) return null; + const upper: SemVer = + v.major > 0 + ? { major: v.major + 1, minor: 0, patch: 0, prerelease: [] } + : v.minor > 0 + ? { major: 0, minor: v.minor + 1, patch: 0, prerelease: [] } + : { major: 0, minor: 0, patch: v.patch + 1, prerelease: [] }; + return [ + { op: ">=", version: v }, + { op: "<", version: upper }, + ]; + } + const tilde = term.match(/^~(.+)$/); + if (tilde) { + const partial = tilde[1].match(PARTIAL_RE); + const v = parseVersion(tilde[1]); + if (!v || !partial) return null; + const upper: SemVer = partial[2] === undefined + ? { major: v.major + 1, minor: 0, patch: 0, prerelease: [] } + : { major: v.major, minor: v.minor + 1, patch: 0, prerelease: [] }; + return [ + { op: ">=", version: v }, + { op: "<", version: upper }, + ]; + } + const hyphen = term.match(/^(.+?)\s+-\s+(.+)$/); + if (hyphen) { + const lo = parseVersion(hyphen[1]); + const hi = parseVersion(hyphen[2]); + if (!lo || !hi) return null; + return [ + { op: ">=", version: lo }, + { op: "<=", version: hi }, + ]; + } + const partial = term.match(PARTIAL_RE); + if (partial && (partial[2] === undefined || /x|\*/i.test(term))) { + const nums = [partial[1], partial[2], partial[3]].map((p) => + p === undefined || /x|\*/i.test(p) ? undefined : parseInt(p, 10), + ); + if (nums[0] === undefined) return []; + const lo: SemVer = { + major: nums[0], + minor: nums[1] ?? 0, + patch: nums[2] ?? 0, + prerelease: [], + }; + const hi: SemVer = nums[1] === undefined + ? { major: lo.major + 1, minor: 0, patch: 0, prerelease: [] } + : nums[2] === undefined + ? { major: lo.major, minor: lo.minor + 1, patch: 0, prerelease: [] } + : lo; + const out: Comparator[] = [{ op: ">=", version: lo }]; + if (nums[2] === undefined) out.push({ op: "<", version: hi }); + else out[0] = { op: "=", version: lo }; + return out; + } + const cmp = parseComparator(term); + return cmp ? [cmp] : null; +}; + +const satisfiesSet = (v: SemVer, comparators: Comparator[]): boolean => + comparators.every((c) => { + const diff = compareVersions(v, c.version); + switch (c.op) { + case ">=": return diff >= 0; + case "<=": return diff <= 0; + case ">": return diff > 0; + case "<": return diff < 0; + case "=": return diff === 0; + } + }); + +/** `satisfies("0.2.0", ">=0.1.0 <1.0.0")` — npm range semantics subset. */ +export const satisfiesRange = (version: string, range: string): boolean => { + const v = parseVersion(version); + if (!v) return false; + const groups = range.split("||").map((g) => g.trim()); + for (const group of groups) { + // Hyphen ranges contain whitespace — expand before the term split. + const hyphen = group.match(/^(.+?)\s+-\s+(.+)$/); + const terms = hyphen + ? [hyphen[0]!] + : group.split(/\s+/).filter((t) => t !== ""); + const comparators: Comparator[] = []; + let ok = true; + for (const term of terms) { + const expanded = expandTerm(term); + if (expanded === null) { + ok = false; + break; + } + comparators.push(...expanded); + } + if (ok && satisfiesSet(v, comparators)) return true; + } + return false; +}; diff --git a/packages/sdk/src/sources.test.ts b/packages/sdk/src/sources.test.ts new file mode 100644 index 0000000..e5dae1d --- /dev/null +++ b/packages/sdk/src/sources.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { cloneUrlFor, resolveSource } from "./sources.js"; + +describe("resolveSource", () => { + it("parses explicit schemes", () => { + expect(resolveSource("git:https://x/y.git").type).toBe("git"); + expect(resolveSource("github:owner/repo").type).toBe("github"); + expect(resolveSource("gh:owner/repo").type).toBe("github"); + expect(resolveSource("registry:foo@1.2.3").type).toBe("registry"); + }); + + it("parses https URLs incl. github tree/subdir", () => { + const s = resolveSource("https://github.com/owner/repo/tree/main/sub/dir"); + expect(s.type).toBe("github"); + expect(s.uri).toBe("owner/repo"); + expect(s.path).toBe("sub/dir"); + expect(s.ref).toBe("main"); + + const plain = resolveSource("https://github.com/owner/repo"); + expect(plain.type).toBe("github"); + expect(plain.uri).toBe("owner/repo"); + }); + + it("parses scp-style git@github.com → github", () => { + const s = resolveSource("git@github.com:owner/repo.git"); + expect(s.type).toBe("github"); + expect(s.uri).toBe("owner/repo"); + expect(cloneUrlFor(s)).toBe("https://github.com/owner/repo.git"); + const other = resolveSource("git@gitlab.com:owner/repo.git"); + expect(other.type).toBe("git"); + }); + + it("owner/repo shorthand → github", () => { + const s = resolveSource("owner/repo/subdir"); + expect(s.type).toBe("github"); + expect(s.uri).toBe("owner/repo"); + expect(s.path).toBe("subdir"); + }); + + it("local path forms → local", () => { + for (const p of ["./pkg", "../pkg", "/abs/path", "~/pkg", "local:./x"]) { + const s = resolveSource(p); + expect(s.type).toBe("local"); + } + }); + + it("bare name[@ver] → registry", () => { + const s = resolveSource("my-plugin@2.0.0"); + expect(s.type).toBe("registry"); + expect(s.uri).toBe("my-plugin"); + expect(s.ref).toBe("2.0.0"); + expect(resolveSource("my-plugin").type).toBe("registry"); + }); + + it("cloneUrlFor github → https .git", () => { + expect(cloneUrlFor(resolveSource("gh:o/r"))).toBe("https://github.com/o/r.git"); + }); + + it("rejects garbage", () => { + expect(() => resolveSource("")).toThrow(); + }); +}); diff --git a/packages/sdk/src/sources.ts b/packages/sdk/src/sources.ts new file mode 100644 index 0000000..f509fd0 --- /dev/null +++ b/packages/sdk/src/sources.ts @@ -0,0 +1,157 @@ +/** + * Source parsing — `resolveSource(input)` produces the lockfile's Source + * object (lockfile.md §3.3) before ref resolution. + * + * Accepted spellings: + * git → `git:`, `git@host:path/repo.git`, `ssh://…`, `git://…`, + * any `https://…` clone URL outside github.com + * github → `github:owner/repo`, `gh:owner/repo`, bare `owner/repo`, + * `https://github.com/owner/repo[.git]`, + * `…/tree//`, `git@github.com:owner/repo.git` + * local → `local:`, `file://`, `./`, `../`, `/abs`, `~/` + * registry→ `registry:` or a bare `name` / `name@version` + * (no `/`) — index-resolved coordinates per the registry note + * + * Refs and monorepo subpaths ride the source string: a `#` fragment + * sets the requested ref; `owner/repo//` sets `path`. + */ + +import type { SourceRef } from "./types.js"; +import { StoreError } from "./errors.js"; + +const GITHUB_HOST_RE = /^(?:www\.)?github\.com$/i; + +const splitRef = (input: string): { base: string; ref?: string } => { + const hash = input.indexOf("#"); + if (hash < 0) return { base: input }; + return { base: input.slice(0, hash), ref: input.slice(hash + 1) || undefined }; +}; + +const githubRef = (owner: string, repo: string, path?: string, ref?: string): SourceRef => ({ + type: "github", + uri: `${owner}/${repo}`, + ref, + path, +}); + +const parseGithubUrl = (url: URL): SourceRef | null => { + const segments = url.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/").filter(Boolean); + if (segments.length < 2) return null; + const [owner, repo, ...rest] = segments; + let ref: string | undefined; + let path: string | undefined; + if (rest.length > 0 && rest[0] === "tree") { + ref = rest[1]; + if (rest.length > 2) path = rest.slice(2).join("/"); + } else if (rest.length > 0) { + // e.g. /owner/repo/sub/dir (degit convention) + path = rest.join("/"); + } + return githubRef(owner, repo, path, ref); +}; + +const parseGithubShorthand = (base: string, ref?: string): SourceRef | null => { + const segments = base.split("/").filter(Boolean); + if (segments.length < 2) return null; + const [owner, repo, ...rest] = segments; + return githubRef(owner, repo, rest.length > 0 ? rest.join("/") : undefined, ref); +}; + +/** + * Parse a source string into a `SourceRef`. Throws `StoreError("invalid")` + * on unparseable input — the bridge maps it to `-32602`. + */ +export const resolveSource = (input: string): SourceRef => { + const trimmed = input.trim(); + if (trimmed === "") throw new StoreError("invalid", "empty source string"); + + const { base, ref } = splitRef(trimmed); + + // Explicit schemes first. + const scheme = base.match(/^([a-z][a-z0-9-]*):(.*)$/i); + if (scheme && !/^[a-zA-Z]:[\\/]/.test(base)) { + const [, kind, rest] = scheme; + switch (kind.toLowerCase()) { + case "git": + return { type: "git", uri: rest, ref }; + case "github": + case "gh": { + const gh = parseGithubShorthand(rest, ref); + if (gh) return gh; + return { type: "github", uri: rest, ref }; + } + case "local": + return { type: "local", uri: rest }; + case "file": + return { type: "local", uri: rest.replace(/^\/\//, "") }; + case "registry": + return { type: "registry", uri: rest, ref }; + case "https": + case "http": + case "ssh": + case "git+ssh": + case "git+https": + break; // handled by URL parsing below + default: + throw new StoreError("invalid", `unknown source scheme: ${kind}`); + } + } + + // URLs. + try { + const url = new URL(base); + if (url.protocol === "file:") return { type: "local", uri: url.pathname }; + if (GITHUB_HOST_RE.test(url.hostname)) { + const gh = parseGithubUrl(url); + if (gh) { + if (gh.ref === undefined) gh.ref = ref; + return gh; + } + } + return { type: "git", uri: base, ref }; + } catch { + /* not a URL — fall through */ + } + + // SCP-style SSH (git@host:path/repo.git). + const scp = base.match(/^[^/@\s]+@([^:/\s]+):(.+)$/); + if (scp) { + if (GITHUB_HOST_RE.test(scp[1])) { + const gh = parseGithubShorthand(scp[2].replace(/\.git$/, ""), ref); + if (gh) return gh; + } + return { type: "git", uri: base, ref }; + } + + // Explicitly-local spellings. + if ( + base.startsWith("./") || + base.startsWith("../") || + base.startsWith("/") || + base.startsWith("~/") || + base === "." || + base === ".." + ) + return { type: "local", uri: base }; + + // owner/repo[/sub/dir] shorthand → github. + if (base.includes("/")) { + const gh = parseGithubShorthand(base, ref); + if (gh) return gh; + } + + // Bare coordinates → registry (`name` or `name@version`). + const coords = base.match(/^([a-zA-Z0-9._-]+?)(?:@(.+))?$/); + if (coords) { + const [, name, version] = coords; + return { type: "registry", uri: name, ref: ref ?? version }; + } + + throw new StoreError("invalid", `unparseable source: ${input}`); +}; + +/** The git clone URL a SourceRef resolves to (github → https clone). */ +export const cloneUrlFor = (source: SourceRef): string => + source.type === "github" + ? `https://github.com/${source.uri}.git` + : source.uri; diff --git a/packages/sdk/src/store.test.ts b/packages/sdk/src/store.test.ts new file mode 100644 index 0000000..e58143a --- /dev/null +++ b/packages/sdk/src/store.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { createStore } from "./store.js"; +import { createTestPorts } from "./testing.js"; +import type { StorePorts } from "./ports.js"; + +const ROOT = "/agents"; + +const makePluginTree = (fs: ReturnType["fs"], dir: string, over?: { name?: string; skill?: boolean; hooks?: boolean; mcp?: boolean; commands?: string[] }) => { + const name = over?.name ?? "demo-plugin"; + fs.putFile( + `${dir}/plugin.json`, + JSON.stringify({ + $schema: "https://agents.json/plugin.schema.json", + name, + version: "1.0.0", + extensions: { "dev.anyharness": { namespaceVersion: 1, capabilities: ["storage.fs"] } }, + }), + ); + if (over?.mcp !== false) + fs.putFile(`${dir}/mcp.json`, JSON.stringify({ mcpServers: { weather: { command: "w-srv" } } })); + if (over?.hooks) + fs.putFile(`${dir}/dev.anyharness/hooks.json`, JSON.stringify({ hooks: { "tool.before": [{ command: "./hook.sh" }] } })); + for (const c of over?.commands ?? []) + fs.putFile(`${dir}/dev.anyharness/commands/${c}.md`, `---\ndescription: run ${c}\n---\nDo ${c} now.`); + fs.putFile(`${dir}/dev.anyharness/rules/r.md`, "Be nice."); +}; + +const makeSkillTree = (fs: ReturnType["fs"], dir: string, name = "my-skill") => { + fs.putFile(`${dir}/SKILL.md`, `---\nname: ${name}\ndescription: test skill\n---\nBody of ${name}.`); +}; + +const storeAt = (ports: StorePorts) => createStore(ROOT, ports); + +describe("createStore — local source installs", () => { + it("installs a plugin into packages/ + writes lockfile entry", async () => { + const ports = createTestPorts(); + makePluginTree(ports.fs, "/src/demo"); + const store = storeAt(ports); + const res = await store.install("/src/demo", { actor: "user" }); + expect(res.extension.id).toBe("demo-plugin@1.0.0"); + expect(res.location).toBe(`${ROOT}/harness/packages/demo-plugin`); + + const lock = await store.readLock(); + const entry = lock.extensions["demo-plugin"]; + expect(entry).toBeTruthy(); + expect(entry.integrity).toMatch(/^sha256-/); + expect(entry.targets).toContain("mcp"); + + // MCP merge into shared ~/.agents/mcp.json. + const mcp = JSON.parse(new TextDecoder().decode(await ports.fs.readFile(`${ROOT}/mcp.json`))); + expect(mcp.mcpServers.weather.command).toBe("w-srv"); + + // audit.log got an install event. + const audit = await store.auditLog(); + expect(audit.some((r) => r.event === "install")).toBe(true); + }); + + it("list/get/remove round-trip; setEnabled toggles persisted flag", async () => { + const ports = createTestPorts(); + makePluginTree(ports.fs, "/src/demo"); + const store = storeAt(ports); + await store.install("/src/demo"); + + expect((await store.list()).map((e) => e.id)).toEqual(["demo-plugin@1.0.0"]); + expect((await store.get("demo-plugin")).entry.manifest.name).toBe("demo-plugin"); + expect((await store.get("demo-plugin@1.0.0")).extension.manifest.name).toBe("demo-plugin"); + + await store.setEnabled("demo-plugin", false); + expect((await store.get("demo-plugin")).extension.enabled).toBe(false); + expect((await store.list({ enabledOnly: true }))).toEqual([]); + await store.setEnabled("demo-plugin", true); + + await store.remove("demo-plugin"); + expect(await store.list()).toEqual([]); + expect((await ports.fs.stat(`${ROOT}/harness/packages/demo-plugin`))).toBeNull(); + expect((await store.auditLog()).some((r) => r.event === "remove")).toBe(true); + }); + + it("reinstall requires update flag", async () => { + const ports = createTestPorts(); + makePluginTree(ports.fs, "/src/demo"); + const store = storeAt(ports); + await store.install("/src/demo"); + await expect(store.install("/src/demo")).rejects.toMatchObject({ kind: "conflict" }); + const res = await store.install("/src/demo", { update: true }); + expect(res.extension.id).toBe("demo-plugin@1.0.0"); + expect((await store.auditLog()).some((r) => r.event === "update")).toBe(true); + }); + + it("standalone SKILL.md installs as skill into shared skills/ (§5.1 default)", async () => { + const ports = createTestPorts(); + makeSkillTree(ports.fs, "/src/sk", "cool-skill"); + const store = storeAt(ports); + const res = await store.install("/src/sk"); + expect(res.extension.kind).toBe("skill"); + expect(res.location).toBe(`${ROOT}/skills/cool-skill`); + const lock = await store.readLock(); + expect(lock.extensions["cool-skill"].targets).toContain("skills"); + }); + + it("verify detects integrity drift; doctor reports lock vs fs", async () => { + const ports = createTestPorts(); + makePluginTree(ports.fs, "/src/demo"); + const store = storeAt(ports); + await store.install("/src/demo"); + expect((await store.verify("demo-plugin")).ok).toBe(true); + + ports.fs.putFile(`${ROOT}/harness/packages/demo-plugin/extra.txt`, "tampered"); + const bad = await store.verify("demo-plugin"); + expect(bad.ok).toBe(false); + + const report = await store.doctor(); + expect(report.findings.some((f) => f.extension === "demo-plugin")).toBe(true); + }); + + it("policy denies a denied source", async () => { + const ports = createTestPorts(); + makePluginTree(ports.fs, "/src/demo"); + ports.fs.putFile( + `${ROOT}/harness/config.toml`, + `[policy.sources]\ndeny = ["**"]\n`, + ); + const store = storeAt(ports); + await expect(store.install("/src/demo")).rejects.toMatchObject({ kind: "policy-denied" }); + }); + + it("corrupt lockfile is preserved aside + audited", async () => { + const ports = createTestPorts(); + ports.fs.putFile(`${ROOT}/harness/extensions.lock`, "{broken"); + const store = storeAt(ports); + const lock = await store.readLock(); + expect(lock.extensions).toEqual({}); + const audit = await store.auditLog(); + expect(audit.some((r) => r.event === "lockfile.corrupt")).toBe(true); + }); + + it("materialize: plugin skills land in shared skills/, store-target updates targets", async () => { + const ports = createTestPorts(); + ports.fs.putFile( + "/src/plug/plugin.json", + JSON.stringify({ $schema: "x", name: "plug", version: "1.0.0", extensions: { "dev.anyharness": { namespaceVersion: 1 } } }), + ); + ports.fs.putFile("/src/plug/skills/helper/SKILL.md", "---\nname: helper\n---\nDo it."); + const store = storeAt(ports); + await store.install("/src/plug"); + const res = await store.materialize("plug", { include: ["helper"] }); + expect(res.skills[0].materializedTo).toBe(`${ROOT}/skills/helper`); + expect((await ports.fs.stat(`${ROOT}/skills/helper/SKILL.md`))?.type).toBe("file"); + const lock = await store.readLock(); + expect(lock.extensions["plug"].targets).toContain("skills"); + }); +}); diff --git a/packages/sdk/src/store.ts b/packages/sdk/src/store.ts new file mode 100644 index 0000000..931a4c8 --- /dev/null +++ b/packages/sdk/src/store.ts @@ -0,0 +1,1076 @@ +/** + * `createStore` — the AnyHarness store over `~/.agents/` (spec/store-layout.md). + * + * `root` is the agents root itself (`~/.agents/`); the store derives + * `harness/`, `skills/`, `mcp.json` beneath it. Every mutation holds + * `harness/.lock`, stages through `harness/tmp/`, and completes by atomic + * rename; every trust-relevant event lands in `audit.log`. + */ + +import { FsPort, StorePorts } from "./ports.js"; +import { StoreError, conflict, notFound, policyDenied, trustViolation } from "./errors.js"; +import { atomicWriteFile, copyTree, renameIntoPlace, swapDirectory } from "./atomic.js"; +import { computeIntegrity, listPackageFiles, type PackageFile } from "./integrity.js"; +import { emptyLockfile, encodeLockfile, readLockfile, serializeLockfile, type LockfileRead } from "./lockfile.js"; +import { + inspectPackage, + isValidExtensionName, + type PackageInspection, + type ParsedManifest, +} from "./manifest.js"; +import { mergeMcpServers, readRootMcp, removeMcpServers, rootMcpPathFor } from "./mcpmerge.js"; +import { loadPolicy, resolveExecDecision, sourceAllowed } from "./policy.js"; +import { cloneUrlFor, resolveSource } from "./sources.js"; +import { appendAudit, readAudit } from "./audit.js"; +import { MutexOptions, lockPathFor, withLock } from "./mutex.js"; +import { dirname, join } from "./path.js"; +import type { + Actor, + AuditEvent, + AuditRecord, + Extension, + ExtensionKind, + LockEntry, + Lockfile, + ManifestRef, + SourceRef, + TrustPolicy, +} from "./types.js"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const STORE_LAYOUT_VERSION = 1; +/** targets[] marker for materialization into the shared skills root (§5.1). */ +export const SHARED_SKILLS_TARGET = "skills"; + +export interface StorePaths { + /** `~/.agents/` (the value passed as `root`). */ + root: string; + /** `~/.agents/harness/` — our sole owned key. */ + harness: string; + packages: string; + data: string; + tmp: string; + lockfile: string; + config: string; + audit: string; + storeJson: string; + lock: string; + /** Shared roots we read or merge into but never own. */ + skills: string; + mcpJson: string; +} + +export const storePaths = (root: string): StorePaths => { + const harness = join(root, "harness"); + return { + root, + harness, + packages: join(harness, "packages"), + data: join(harness, "data"), + tmp: join(harness, "tmp"), + lockfile: join(harness, "extensions.lock"), + config: join(harness, "config.toml"), + audit: join(harness, "audit.log"), + storeJson: join(harness, "store.json"), + lock: lockPathFor(harness), + skills: join(root, "skills"), + mcpJson: rootMcpPathFor(root), + }; +}; + +/** Approval callback — cli wires a TTY prompt; absent = non-interactive. */ +export type ApproveExec = (req: { + execClass: "setup" | "hooks" | "mcp" | "skillScripts"; + extension: ManifestRef; + command: string; + args: string[]; + reason: string; +}) => Promise; + +export interface StoreOptions { + /** Actor recorded in audit events and used for policy (default "user"). */ + actor?: Actor; + /** Implementation identifier for store.json (default "anyharness-sdk"). */ + createdBy?: string; + /** Interactive approver for `ask` decisions; absence = no TTY. */ + approveExec?: ApproveExec; + mutex?: MutexOptions; +} + +export interface ListOptions { + kinds?: ExtensionKind[]; + enabledOnly?: boolean; +} + +export interface InstallOptions { + actor?: Actor; + /** Allow an existing entry to be replaced (trust.md §3.3 update path). */ + update?: boolean; + /** Where a standalone (kind=skill) package lands. Default "shared". */ + installTarget?: "shared" | "packages"; + /** Capability slots to grant (subset of requested); default grants all requested. */ + grantCapabilities?: string[]; + approveExec?: ApproveExec; + /** Keep staged path for tests/debug — skips rename and leaves tmp/. */ + dryRun?: boolean; +} + +export interface InstallResult { + extension: Extension; + entry: LockEntry; + /** Where the package tree landed (packages/ or skills/). */ + location: string; + warnings: string[]; +} + +export interface MaterializeOptions { + target?: "store" | "inline"; + /** Skill names to materialize; default = all provided skills. */ + include?: string[]; + actor?: Actor; +} + +export interface MaterializedSkill { + name: string; + manifest: ManifestRef; + files: (PackageFile & { content?: string; contentBase64?: string })[]; + materializedTo?: string; +} + +export interface MaterializeResult { + skills: MaterializedSkill[]; +} + +export interface VerifyResult { + ok: boolean; + expected?: string; + actual: string; + extension: ManifestRef; +} + +export interface DoctorFinding { + code: string; + severity: "error" | "warning" | "info"; + message: string; + extension?: string; + path?: string; +} + +export interface DoctorReport { + findings: DoctorFinding[]; +} + +export interface StoreEntry { + name: string; + extension: Extension; + entry: LockEntry; + /** Absolute dir of the installed tree (packages/ or skills/). */ + packageDir: string; +} + +export interface StoreNotification { + kind: string; + data?: Record; + streamId?: string; +} + +/** Pinned public surface (plan Wave 2) plus additive helpers. */ +export interface Store { + readonly root: string; + readonly paths: StorePaths; + readonly ports: StorePorts; + list(opts?: ListOptions): Promise; + /** One entry by name or "@" id — additive helper. */ + get(nameOrId: string): Promise; + install(source: string | SourceRef, opts?: InstallOptions): Promise; + remove(name: string, opts?: { actor?: Actor; keepData?: boolean }): Promise; + setEnabled(name: string, enabled: boolean, opts?: { actor?: Actor }): Promise; + materialize(name: string, opts?: MaterializeOptions): Promise; + verify(name: string, opts?: { actor?: Actor }): Promise; + doctor(): Promise; + /** Current effective trust policy (config.toml `[policy]`). */ + policy(): Promise; + /** Read the audit log (tolerating torn lines). */ + auditLog(): Promise; + /** Subscribe to store-change notifications (extensions.changed et al). */ + subscribe(listener: (n: StoreNotification) => void): () => void; + /** Internal: raw lockfile read (bridge + tests). */ + readLock(): Promise; +} + +/* ------------------------------------------------------------------ */ + +interface Ctx { + fs: FsPort; + ports: StorePorts; + paths: StorePaths; + options: StoreOptions; + listeners: Set<(n: StoreNotification) => void>; +} + +const notify = (ctx: Ctx, n: StoreNotification): void => { + for (const l of ctx.listeners) l(n); +}; + +const actorOf = (ctx: Ctx, opts?: { actor?: Actor }): Actor => + opts?.actor ?? ctx.options.actor ?? "user"; + +const audit = async ( + ctx: Ctx, + event: AuditEvent, + fields: Omit, +): Promise => { + await appendAudit(ctx.fs, ctx.paths.audit, { + ts: new Date().toISOString(), + event, + ...fields, + }); +}; + +/** Create the harness/ scaffold + store.json on first write (§8.3). */ +const ensureStore = async (ctx: Ctx): Promise => { + for (const dir of [ctx.paths.harness, ctx.paths.packages, ctx.paths.data, ctx.paths.tmp]) + await ctx.fs.mkdir(dir); + const st = await ctx.fs.stat(ctx.paths.storeJson); + if (st !== null) { + try { + const doc = JSON.parse(decoder.decode(await ctx.fs.readFile(ctx.paths.storeJson))); + const lv = doc?.layoutVersion; + if (typeof lv === "number" && lv > STORE_LAYOUT_VERSION) + throw new StoreError( + "invalid", + `store layoutVersion ${lv} > supported ${STORE_LAYOUT_VERSION}`, + { layoutVersion: lv }, + ); + } catch (e) { + if (e instanceof StoreError) throw e; + throw new StoreError("invalid", `store.json is unreadable: ${(e as Error).message}`); + } + return; + } + await atomicWriteFile( + ctx.fs, + ctx.paths.storeJson, + encoder.encode( + JSON.stringify( + { + layoutVersion: STORE_LAYOUT_VERSION, + createdAt: new Date().toISOString(), + createdBy: ctx.options.createdBy ?? "anyharness-sdk", + }, + null, + 2, + ) + "\n", + ), + ); +}; + +/** Read the lockfile; on corruption stash the file aside once. */ +const loadLock = async (ctx: Ctx): Promise => { + const read = await readLockfile(ctx.fs, ctx.paths.lockfile); + if (read.corrupt !== undefined) { + // Preserve the corrupt file before any future write (lockfile.md §2.4). + const aside = `${ctx.paths.lockfile}.corrupt-${Date.now()}`; + await ctx.fs.rename(ctx.paths.lockfile, aside).catch(() => undefined); + await audit(ctx, "lockfile.corrupt", { + actor: actorOf(ctx), + details: { reason: read.corrupt.reason, preservedAs: aside }, + }).catch(() => undefined); + } + return read; +}; + +const writeLock = async (ctx: Ctx, lock: Lockfile): Promise => { + await atomicWriteFile(ctx.fs, ctx.paths.lockfile, encodeLockfile(lock)); +}; + +/* ---------------- extension <-> entry mapping ---------------------- */ + +const providesOf = (entry: LockEntry): ExtensionKind[] => { + if (entry.components === undefined || entry.components.length === 0) + return [entry.kind]; + const kinds = new Set(); + for (const c of entry.components) kinds.add(c.kind); + kinds.delete(entry.kind); + return kinds.size === 0 ? [entry.kind] : [...kinds]; +}; + +const toExtension = (name: string, entry: LockEntry): Extension => ({ + id: `${name}@${entry.manifest.version}`, + kind: entry.kind, + manifest: { + name: entry.manifest.name, + version: entry.manifest.version, + integrity: entry.integrity, + }, + enabled: entry.enabled !== false, + provides: providesOf(entry), +}); + +/** Installed-tree location: standalone skills live in the shared root. */ +const packageDirOf = (ctx: Ctx, name: string, entry: LockEntry): string => + entry.kind === "skill" && (entry.targets ?? []).includes(SHARED_SKILLS_TARGET) + ? join(ctx.paths.skills, name) + : join(ctx.paths.packages, name); + +const lookupName = (lock: Lockfile, nameOrId: string): string => { + if (nameOrId in lock.extensions) return nameOrId; + const at = nameOrId.lastIndexOf("@"); + if (at > 0) { + const name = nameOrId.slice(0, at); + if (name in lock.extensions) return name; + } + throw notFound("extension", nameOrId); +}; + +/* ------------------------------- staging -------------------------- */ + +const stageId = (): string => + `stage-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const runGit = async ( + ctx: Ctx, + args: string[], + cwd?: string, +): Promise<{ stdout: string; stderr: string }> => { + const res = await ctx.ports.exec.run("git", args, { cwd }); + if (res.code !== 0) + throw new StoreError("invalid", `git ${args[0]} failed: ${res.stderr.trim()}`, { + command: "git", + args, + code: res.code, + }); + return { stdout: res.stdout, stderr: res.stderr }; +}; + +/** + * Fetch a source into `stageDir` (contents directly at the stage root). + * Returns the resolved `source` (refs pinned for git/github). + */ +const fetchInto = async ( + ctx: Ctx, + source: SourceRef, + stageDir: string, +): Promise => { + await ctx.fs.mkdir(stageDir); + if (source.type === "local") { + const srcAbs = source.uri.startsWith("/") + ? source.uri + : join(ctx.paths.root, source.uri); // store-relative per lockfile §3.3 + const st = await ctx.fs.stat(srcAbs); + if (st === null || st.type !== "directory") + throw notFound("source", source.uri); + await copyTree(ctx.fs, srcAbs, stageDir); + // .git is source bookkeeping, never package content. + await ctx.fs.remove(join(stageDir, ".git"), { recursive: true }); + return source; + } + if (source.type === "registry") + throw new StoreError( + "invalid", + "registry sources resolve through an index; no index is configured in this build", + { source: source.uri }, + ); + + // git / github → clone, pin, strip .git. + const url = cloneUrlFor(source); + const cloneDir = `${stageDir}-clone`; + await runGit(ctx, ["clone", "--quiet", url, cloneDir]); + if (source.ref !== undefined) { + const wanted = source.ref; + await runGit(ctx, ["fetch", "--quiet", "--depth", "1", "origin", wanted], cloneDir).catch( + () => runGit(ctx, ["fetch", "--quiet", "origin", wanted], cloneDir), + ); + await runGit(ctx, ["checkout", "--quiet", "--detach", "FETCH_HEAD"], cloneDir); + } + const head = (await runGit(ctx, ["rev-parse", "HEAD"], cloneDir)).stdout.trim(); + const pkgRoot = source.path !== undefined ? join(cloneDir, source.path) : cloneDir; + const st = await ctx.fs.stat(pkgRoot); + if (st === null || st.type !== "directory") + throw new StoreError("invalid", `source path absent: ${source.path ?? "/"}`); + await ctx.fs.remove(join(pkgRoot, ".git"), { recursive: true }); + if (pkgRoot !== cloneDir) { + await ctx.fs.rename(pkgRoot, stageDir); + await ctx.fs.remove(cloneDir, { recursive: true }); + } else { + await ctx.fs.rename(cloneDir, stageDir); + } + return { ...source, ref: head }; +}; + +/* ------------------------------- install --------------------------- */ + +const installOne = async ( + ctx: Ctx, + sourceInput: string | SourceRef, + opts: InstallOptions, +): Promise => { + const actor = actorOf(ctx, opts); + const requested = + typeof sourceInput === "string" ? resolveSource(sourceInput) : sourceInput; + + const { policy } = await loadPolicy(ctx.fs, ctx.paths.config); + if (!sourceAllowed(policy, requested)) { + await audit(ctx, "install", { + actor, + decision: "deny", + source: requested, + details: { reason: "source not allowed by policy" }, + }); + throw policyDenied("sources.allow/sources.deny", `source denied by policy: ${requested.uri}`); + } + + return withLock(ctx.fs, ctx.paths.lock, ctx.options.mutex ?? {}, async () => { + await ensureStore(ctx); + const lockRead = await loadLock(ctx); + const lock = lockRead.lockfile; + + const stageDir = join(ctx.paths.tmp, stageId()); + let resolved: SourceRef; + try { + resolved = await fetchInto(ctx, requested, stageDir); + } catch (e) { + await ctx.fs.remove(stageDir, { recursive: true }).catch(() => undefined); + throw e; + } + + const finish = async (err?: unknown): Promise => { + await ctx.fs.remove(stageDir, { recursive: true }).catch(() => undefined); + throw err ?? new StoreError("internal", "install aborted"); + }; + + // Inspect staged tree. + const inspection = await inspectPackage(ctx.fs, stageDir); + const fatal = inspection.issues.filter((i) => i.level === "error"); + if (inspection.manifest === undefined && inspection.standaloneSkill === undefined) + await finish(new StoreError("manifest-invalid", + "no plugin.json and no root SKILL.md — not a package", { + issues: inspection.issues.map((i) => i.message), + })); + if (fatal.length > 0) + await finish(new StoreError("manifest-invalid", + `manifest invalid: ${fatal.map((i) => i.message).join("; ")}`, { + issues: inspection.issues.map((i) => i.message), + })); + + const isSkill = inspection.standaloneSkill !== undefined; + const name = isSkill + ? inspection.standaloneSkill!.name + : inspection.manifest!.name; + const version = isSkill + ? inspection.standaloneSkill!.version + : inspection.manifest!.version; + const manifestRef: ManifestRef = { name, version }; + const kind: ExtensionKind = isSkill ? "skill" : "plugin"; + const installTarget = isSkill ? (opts.installTarget ?? "shared") : "packages"; + + if (!isValidExtensionName(name)) + await finish(new StoreError("manifest-invalid", + `extension name violates Agent Plugins §5.5: ${name}`)); + + const existing = lock.extensions[name]; + if (existing !== undefined && opts.update !== true) + await finish(conflict( + `extension already installed: ${name} (use update to replace)`)); + + const destDir = + installTarget === "shared" + ? join(ctx.paths.skills, name) + : join(ctx.paths.packages, name); + + // §7.3.3: never shadow a foreign skills// dir. + if (installTarget === "shared") { + const destStat = await ctx.fs.stat(destDir); + const weManage = existing !== undefined; + if (destStat !== null && !weManage) { + await audit(ctx, "skills.conflict", { + actor, + extension: manifestRef, + details: { dir: destDir, reason: "foreign skill directory exists" }, + }); + await finish(conflict( + `skills/${name} exists and is not AnyHarness-managed — refusing to shadow a foreign skill`)); + } + } else { + const destStat = await ctx.fs.stat(destDir); + if (destStat !== null && existing === undefined) + await finish(conflict( + `packages/${name} exists without a lock entry — refusing to overwrite an orphan`)); + } + + // Integrity over the staged tree (pre-rename content is post-rename content). + const integrity = await computeIntegrity(ctx.fs, stageDir); + + // Capability grant: requested ∩ granted (default: grant all requested). + const requestedCaps = inspection.manifest?.namespace?.capabilities ?? []; + const grantedCaps = opts.grantCapabilities === undefined + ? requestedCaps + : requestedCaps.filter((c) => opts.grantCapabilities!.includes(c)); + + if (opts.dryRun === true) { + const entry: LockEntry = { + kind, + manifest: manifestRef, + source: resolved, + integrity, + installedAt: existing?.installedAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + targets: installTarget === "shared" ? [SHARED_SKILLS_TARGET] : existing?.targets ?? [], + components: inspection.components, + capabilities: grantedCaps, + enabled: existing?.enabled, + }; + return { // dry-run: nothing moved, nothing written + extension: toExtension(name, entry), + entry, + location: stageDir, + warnings: inspection.issues.map((i) => i.message), + } satisfies InstallResult; + } + + // Move into place (atomic rename; swap on update). + await ctx.fs.mkdir(dirname(destDir)); + if (existing !== undefined || (await ctx.fs.stat(destDir)) !== null) + await swapDirectory(ctx.fs, stageDir, destDir); + else await renameIntoPlace(ctx.fs, stageDir, destDir); + await ctx.fs.mkdir(join(ctx.paths.data, name)); + + // dev.anyharness/setup — script policy decides whether it runs. + if (inspection.hasSetupScript) { + const decision = resolveExecDecision(policy, "setup", actor, resolved); + let runAllowed = decision === "allow"; + if (decision === "ask" && opts.approveExec !== undefined) + runAllowed = await opts.approveExec({ + execClass: "setup", + extension: manifestRef, + command: join(destDir, "dev.anyharness", "setup"), + args: [], + reason: "dev.anyharness/setup install script", + }); + if (runAllowed) { + await audit(ctx, "exec.allow", { actor, extension: manifestRef, decision: "allow", details: { class: "setup" } }); + const res = await ctx.ports.exec.run(join(destDir, "dev.anyharness", "setup"), [], { + cwd: destDir, + env: { PLUGIN_ROOT: destDir, PLUGIN_DATA: join(ctx.paths.data, name) }, + }); + if (res.code !== 0) { + await swapDirectory(ctx.fs, destDir, join(ctx.paths.tmp, `${stageId()}-rollback`)); + await audit(ctx, "install", { actor, extension: manifestRef, decision: "deny", source: resolved, details: { setupFailed: true, code: res.code } }); + throw new StoreError("invalid", `setup script failed (exit ${res.code})`, { stderr: res.stderr.slice(0, 2000) }); + } + } else { + await audit(ctx, "exec.deny", { actor, extension: manifestRef, decision: "deny", details: { class: "setup", resolved: decision } }); + } + } + + // mcp.json merge — only member names we declare. + let mcpMerged = false; + if (inspection.mcpServers.length > 0) { + await mergeMcpServers( + ctx.fs, + ctx.paths.mcpJson, + inspection.mcpServers, + destDir, + join(ctx.paths.data, name), + ); + mcpMerged = true; + } + + const now = new Date().toISOString(); + const entry: LockEntry = { + kind, + manifest: manifestRef, + source: resolved, + integrity, + installedAt: existing?.installedAt ?? now, + updatedAt: now, + targets: [ + ...new Set([ + ...(installTarget === "shared" + ? [...(existing?.targets ?? []), SHARED_SKILLS_TARGET] + : (existing?.targets ?? []).filter((t) => t !== SHARED_SKILLS_TARGET)), + ...(mcpMerged ? ["mcp"] : []), + ]), + ], + components: inspection.components, + capabilities: grantedCaps, + enabled: existing?.enabled ?? true, + }; + + lock.extensions[name] = entry; + await writeLock(ctx, lock); + await audit(ctx, existing !== undefined ? "update" : "install", { + actor, + extension: manifestRef, + integrity, + source: resolved, + }); + notify(ctx, { + kind: "extensions.changed", + data: existing !== undefined ? { updated: [name] } : { added: [name] }, + }); + + return { + extension: toExtension(name, entry), + entry, + location: destDir, + warnings: inspection.issues.map((i) => i.message), + } satisfies InstallResult; + }); +}; + +/* ------------------------------- remove ---------------------------- */ + +const removeOne = async ( + ctx: Ctx, + nameOrId: string, + opts: { actor?: Actor; keepData?: boolean }, +): Promise => { + const actor = actorOf(ctx, opts); + return withLock(ctx.fs, ctx.paths.lock, ctx.options.mutex ?? {}, async () => { + const lockRead = await loadLock(ctx); + const lock = lockRead.lockfile; + const name = lookupName(lock, nameOrId); + const entry = lock.extensions[name]; + const dir = packageDirOf(ctx, name, entry); + + // Only remove a skills// dir we manage (§7.3). + if (dir.startsWith(ctx.paths.skills + "/") || dir === ctx.paths.skills) + if (!entry.targets.includes(SHARED_SKILLS_TARGET)) + throw policyDenied("skills/ownership", `skills/${name} is not AnyHarness-managed`); + + await ctx.fs.remove(dir, { recursive: true }); + if (opts.keepData !== true) + await ctx.fs.remove(join(ctx.paths.data, name), { recursive: true }); + + const mcpNames = (entry.components ?? []) + .filter((c) => c.kind === "mcp") + .map((c) => c.name); + if (mcpNames.length > 0) + await removeMcpServers(ctx.fs, ctx.paths.mcpJson, mcpNames); + + delete lock.extensions[name]; + await writeLock(ctx, lock); + await audit(ctx, "remove", { + actor, + extension: { name, version: entry.manifest.version }, + }); + notify(ctx, { kind: "extensions.changed", data: { removed: [name] } }); + return toExtension(name, entry); + }); +}; + +/* --------------------------- materialize --------------------------- */ + +const readSkillFiles = async ( + fs: FsPort, + dir: string, + inline: boolean, +): Promise<(PackageFile & { content?: string; contentBase64?: string })[]> => { + const files = await listPackageFiles(fs, dir); + if (!inline) return files; + const out: (PackageFile & { content?: string; contentBase64?: string })[] = []; + for (const f of files) { + const abs = join(dir, f.path); + const st = await fs.stat(abs); + if (st?.type === "symlink") { + const target = await fs.readlink(abs); + const resolved = target.startsWith("/") ? target : join(dirname(abs), target); + const data = await fs.readFile(resolved); + out.push({ ...f, contentBase64: encodeBinary(data) }); + continue; + } + const data = await fs.readFile(abs); + const text = tryDecodeText(data); + out.push(text !== null ? { ...f, content: text } : { ...f, contentBase64: encodeBinary(data) }); + } + return out; +}; + +const tryDecodeText = (data: Uint8Array): string | null => { + if (data.includes(0)) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(data); + } catch { + return null; + } +}; + +const encodeBinary = (data: Uint8Array): string => { + let s = ""; + for (const b of data) s += String.fromCharCode(b); + // base64 without btoa (not universal in non-secure contexts) + const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let out = ""; + for (let i = 0; i < data.length; i += 3) { + const b0 = data[i], b1 = data[i + 1], b2 = data[i + 2]; + out += B64[b0 >> 2]; + out += B64[((b0 & 3) << 4) | ((b1 ?? 0) >> 4)]; + out += i + 1 < data.length ? B64[((b1 ?? 0) & 0xf) << 2 | ((b2 ?? 0) >> 6)] : "="; + out += i + 2 < data.length ? B64[(b2 ?? 0) & 0x3f] : "="; + } + return out; +}; + +/** Skill dirs a package contributes: {dirName → abs path under pkg}. */ +const skillDirsOf = async ( + ctx: Ctx, + pkgDir: string, + inspection?: PackageInspection, +): Promise<{ name: string; dir: string }[]> => { + const insp = inspection ?? (await inspectPackage(ctx.fs, pkgDir)); + const out: { name: string; dir: string }[] = []; + for (const c of insp.components) { + if (c.kind !== "skill") continue; + if (insp.standaloneSkill !== undefined && c.name === insp.standaloneSkill.name) { + out.push({ name: c.name, dir: pkgDir }); + continue; + } + const dir = join(pkgDir, "skills", c.name); + if ((await ctx.fs.stat(join(dir, "SKILL.md"))) !== null) + out.push({ name: c.name, dir }); + } + return out; +}; + +/** Skill dirs the lockfile says `name` manages in the shared root. */ +const managedSkillsFor = (entry: LockEntry): string[] => + entry.targets.includes(SHARED_SKILLS_TARGET) + ? (entry.components ?? []).filter((c) => c.kind === "skill").map((c) => c.name) + : []; + +const materializeOne = async ( + ctx: Ctx, + nameOrId: string, + opts: MaterializeOptions, +): Promise => { + const actor = actorOf(ctx, opts); + const lockRead = await loadLock(ctx); + const name = lookupName(lockRead.lockfile, nameOrId); + const entry = lockRead.lockfile.extensions[name]; + if (entry.enabled === false) + throw new StoreError("invalid", `extension disabled: ${name}`); + const pkgDir = packageDirOf(ctx, name, entry); + + // Trust gate: verify before exposing content (trust.md §3.2). + const actual = await computeIntegrity(ctx.fs, pkgDir); + if (actual !== entry.integrity) { + await audit(ctx, "integrity.fail", { + actor, + extension: { name, version: entry.manifest.version }, + integrity: actual, + details: { expected: entry.integrity }, + }); + throw trustViolation(entry.integrity, actual); + } + + const skills = await skillDirsOf(ctx, pkgDir); + const wanted = opts.include === undefined + ? skills + : skills.filter((s) => opts.include!.includes(s.name)); + if (wanted.length === 0) + throw notFound("skill", opts.include?.join(",") ?? name); + + const target = opts.target ?? "store"; + const result: MaterializedSkill[] = []; + + if (target === "store") { + return withLock(ctx.fs, ctx.paths.lock, ctx.options.mutex ?? {}, async () => { + const inner = await readLockfile(ctx.fs, ctx.paths.lockfile); + const innerEntry = inner.lockfile.extensions[name]; + if (innerEntry === undefined) throw notFound("extension", name); + const managed = new Set(managedSkillsFor(innerEntry)); + for (const skill of wanted) { + const dest = join(ctx.paths.skills, skill.name); + const exists = (await ctx.fs.stat(dest)) !== null; + const alreadyOurs = + managed.has(skill.name) || + (innerEntry.kind === "skill" && skill.name === name); + if (exists && !alreadyOurs) { + await audit(ctx, "skills.conflict", { + actor, + extension: { name, version: innerEntry.manifest.version }, + details: { dir: dest, skill: skill.name }, + }); + throw conflict( + `skills/${skill.name} exists and is foreign — refusing to overwrite (§7.3)`); + } + await ctx.fs.mkdir(ctx.paths.skills); + if (exists) await ctx.fs.remove(dest, { recursive: true }); + await copyTree(ctx.fs, skill.dir, dest); + result.push({ + name: skill.name, + manifest: { name, version: innerEntry.manifest.version, integrity: innerEntry.integrity }, + files: await readSkillFiles(ctx.fs, dest, false), + materializedTo: dest, + }); + } + innerEntry.targets = [...new Set([...innerEntry.targets, SHARED_SKILLS_TARGET])]; + innerEntry.updatedAt = new Date().toISOString(); + await writeLock(ctx, inner.lockfile); + await audit(ctx, "integrity.verify", { + actor, + extension: { name, version: innerEntry.manifest.version }, + integrity: innerEntry.integrity, + details: { materialized: wanted.map((s) => s.name) }, + }); + return { skills: result } satisfies MaterializeResult; + }); + } + + // inline + for (const skill of wanted) + result.push({ + name: skill.name, + manifest: { name, version: entry.manifest.version, integrity: entry.integrity }, + files: await readSkillFiles(ctx.fs, skill.dir, true), + }); + return { skills: result }; +}; + +/* ------------------------------- verify ---------------------------- */ + +const verifyOne = async ( + ctx: Ctx, + nameOrId: string, + opts: { actor?: Actor }, +): Promise => { + const actor = actorOf(ctx, opts); + const lockRead = await loadLock(ctx); + const name = lookupName(lockRead.lockfile, nameOrId); + const entry = lockRead.lockfile.extensions[name]; + const pkgDir = packageDirOf(ctx, name, entry); + if ((await ctx.fs.stat(pkgDir)) === null) + throw notFound("extension", name); + const actual = await computeIntegrity(ctx.fs, pkgDir); + const ok = actual === entry.integrity; + await audit(ctx, ok ? "integrity.verify" : "integrity.fail", { + actor, + extension: { name, version: entry.manifest.version }, + integrity: actual, + details: ok ? undefined : { expected: entry.integrity }, + }); + return { ok, expected: entry.integrity, actual, extension: { name, version: entry.manifest.version } }; +}; + +/* ------------------------------- doctor ---------------------------- */ + +const KNOWN_HARNESS_ENTRIES = new Set([ + "store.json", "packages", "data", "extensions.lock", "config.toml", + "audit.log", "tmp", ".lock", "serve.json", +]); + +const doctorScan = async (ctx: Ctx): Promise => { + const findings: DoctorFinding[] = []; + const push = (f: DoctorFinding): void => void findings.push(f); + + const harnessStat = await ctx.fs.stat(ctx.paths.harness); + if (harnessStat === null) return { findings }; // no store yet — nothing to say + + const storeJsonStat = await ctx.fs.stat(ctx.paths.storeJson); + if (storeJsonStat === null) + push({ code: "store-json.missing", severity: "warning", message: "store.json missing in a non-empty harness/", path: ctx.paths.storeJson }); + else { + try { + const doc = JSON.parse(decoder.decode(await ctx.fs.readFile(ctx.paths.storeJson))); + if (typeof doc?.layoutVersion === "number" && doc.layoutVersion > STORE_LAYOUT_VERSION) + push({ code: "store-json.version", severity: "error", message: `layoutVersion ${doc.layoutVersion} exceeds supported ${STORE_LAYOUT_VERSION}`, path: ctx.paths.storeJson }); + } catch { + push({ code: "store-json.invalid", severity: "error", message: "store.json is not valid JSON", path: ctx.paths.storeJson }); + } + } + + // Lockfile vs directories. + const lockRead = await readLockfile(ctx.fs, ctx.paths.lockfile).catch( + (e): LockfileRead => ({ + lockfile: emptyLockfile(), + corrupt: { reason: (e as Error).message }, + }), + ); + if (lockRead.corrupt !== undefined) + push({ code: "lockfile.corrupt", severity: "error", message: `extensions.lock corrupt: ${lockRead.corrupt.reason}`, path: ctx.paths.lockfile }); + const lock = lockRead.lockfile; + + let pkgDirs: string[] = []; + try { + pkgDirs = (await ctx.fs.readDir(ctx.paths.packages)) + .filter((e) => e.type === "directory") + .map((e) => e.name); + } catch { /* absent */ } + + for (const [name, entry] of Object.entries(lock.extensions)) { + const dir = packageDirOf(ctx, name, entry); + if ((await ctx.fs.stat(dir)) === null) { + push({ code: "package.missing", severity: "error", message: `lock entry has no installed directory: ${dir}`, extension: name, path: dir }); + continue; + } + try { + const actual = await computeIntegrity(ctx.fs, dir); + if (actual !== entry.integrity) + push({ code: "integrity.mismatch", severity: "error", message: `integrity drift: expected ${entry.integrity}, computed ${actual}`, extension: name, path: dir }); + } catch (e) { + push({ code: "integrity.unverifiable", severity: "error", message: (e as Error).message, extension: name, path: dir }); + } + } + for (const dirName of pkgDirs) + if (!(dirName in lock.extensions)) + push({ code: "package.orphan", severity: "warning", message: `packages/${dirName} has no lock entry`, path: join(ctx.paths.packages, dirName) }); + + // Foreign entries under harness/ (§4.5). + try { + for (const e of await ctx.fs.readDir(ctx.paths.harness)) + if (!KNOWN_HARNESS_ENTRIES.has(e.name)) + push({ code: "harness.foreign-entry", severity: "info", message: `unrecognized entry under harness/: ${e.name}`, path: join(ctx.paths.harness, e.name) }); + } catch { /* absent */ } + + // tmp/ residue (crashed installs). + try { + for (const e of await ctx.fs.readDir(ctx.paths.tmp)) + push({ code: "tmp.residue", severity: "info", message: `staged install residue: tmp/${e.name} (readers must ignore)`, path: join(ctx.paths.tmp, e.name) }); + } catch { /* absent */ } + + // mcp.json dialect check (§5.2.4). + const mcp = await readRootMcp(ctx.fs, ctx.paths.mcpJson); + if (mcp.foreignDialect !== undefined) + push({ code: "mcp.foreign-dialect", severity: "warning", message: `root mcp.json not mergeable: ${mcp.foreignDialect}`, path: ctx.paths.mcpJson }); + if (mcp.mcpServers !== null) { + const managed = new Set(); + for (const entry of Object.values(lock.extensions)) + for (const c of entry.components ?? []) + if (c.kind === "mcp") managed.add(c.name); + for (const name of managed) + if (!(name in mcp.mcpServers)) + push({ code: "mcp.missing-member", severity: "warning", message: `managed mcpServers member absent from root mcp.json: ${name}`, path: ctx.paths.mcpJson }); + } + + // skills/ reconciliation (§7.3): our claims vs disk vs skills.sh lock. + const managedSkillDirs = new Map(); + for (const [name, entry] of Object.entries(lock.extensions)) + for (const skillName of managedSkillsFor(entry)) + managedSkillDirs.set(skillName, name); + let skillsSh: Record | null = null; + try { + const doc = JSON.parse(decoder.decode(await ctx.fs.readFile(join(ctx.paths.root, ".skill-lock.json")))); + skillsSh = typeof doc === "object" && doc !== null ? (doc["skills"] as Record ?? doc) : null; + } catch { /* absent/foreign — fine */ } + let skillDirs: string[] = []; + try { + skillDirs = (await ctx.fs.readDir(ctx.paths.skills)) + .filter((e) => e.type === "directory" || e.type === "symlink") + .map((e) => e.name); + } catch { /* absent */ } + for (const dirName of skillDirs) { + const owner = managedSkillDirs.get(dirName); + if (owner !== undefined) continue; + if (skillsSh !== null && dirName in skillsSh) + push({ code: "skills.foreign-skills-sh", severity: "info", message: `skills/${dirName} is skills.sh-managed — left alone`, path: join(ctx.paths.skills, dirName) }); + else + push({ code: "skills.foreign", severity: "info", message: `skills/${dirName} is foreign (not AnyHarness-managed)`, path: join(ctx.paths.skills, dirName) }); + } + for (const [skillName, owner] of managedSkillDirs) + if (!skillDirs.includes(skillName)) + push({ code: "skills.missing", severity: "error", message: `materialized skills/${skillName} for ${owner} is absent`, extension: owner, path: join(ctx.paths.skills, skillName) }); + + // audit.log presence (§6.3.4 — evidence, not enforcement). + if (Object.keys(lock.extensions).length > 0 && (await ctx.fs.stat(ctx.paths.audit)) === null) + push({ code: "audit.missing", severity: "warning", message: "audit.log absent on a store with installed extensions", path: ctx.paths.audit }); + + // A held (possibly stale) lock is informational. + const lockStat = await ctx.fs.stat(ctx.paths.lock); + if (lockStat !== null) + push({ code: "lock.held", severity: "info", message: ".lock file present — a writer is active or a stale lock remains", path: ctx.paths.lock }); + + return { findings }; +}; + +/* ------------------------------- factory --------------------------- */ + +export function createStore(root: string, ports: StorePorts, options?: StoreOptions): Store { + const paths = storePaths(root); + const ctx: Ctx = { + fs: ports.fs, + ports, + paths, + options: options ?? {}, + listeners: new Set(), + }; + + const getEntry = async (nameOrId: string): Promise => { + const lockRead = await loadLock(ctx); + const name = lookupName(lockRead.lockfile, nameOrId); + const entry = lockRead.lockfile.extensions[name]; + return { + name, + entry, + extension: toExtension(name, entry), + packageDir: packageDirOf(ctx, name, entry), + }; + }; + + return { + root, + paths, + ports, + + async list(opts?: ListOptions): Promise { + const lockRead = await loadLock(ctx); + let out = Object.entries(lockRead.lockfile.extensions).map(([n, e]) => toExtension(n, e)); + if (opts?.enabledOnly !== false) + out = out.filter((e) => e.enabled); + if (opts?.kinds !== undefined && opts.kinds.length > 0) + out = out.filter( + (e) => opts.kinds!.includes(e.kind) || + (e.provides ?? []).some((k) => opts.kinds!.includes(k)), + ); + return out; + }, + + get: getEntry, + + install: (source, opts) => installOne(ctx, source, opts ?? {}), + + remove: (name, opts) => removeOne(ctx, name, opts ?? {}), + + async setEnabled(nameOrId, enabled, opts): Promise { + const actor = actorOf(ctx, opts); + return withLock(ctx.fs, ctx.paths.lock, ctx.options.mutex ?? {}, async () => { + const lockRead = await loadLock(ctx); + const name = lookupName(lockRead.lockfile, nameOrId); + const entry = lockRead.lockfile.extensions[name]; + entry.enabled = enabled; + entry.updatedAt = new Date().toISOString(); + await writeLock(ctx, lockRead.lockfile); + // Audit has no enable/disable event in the closed enum — noted in + // the report as a spec gap; the lock write is itself atomic. + void actor; + notify(ctx, { kind: "extensions.changed", data: { updated: [name] } }); + return toExtension(name, entry); + }); + }, + + materialize: (name, opts) => materializeOne(ctx, name, opts ?? {}), + + verify: (name, opts) => verifyOne(ctx, name, opts ?? {}), + + doctor: () => doctorScan(ctx), + + policy: async () => (await loadPolicy(ctx.fs, ctx.paths.config)).policy, + + auditLog: async () => (await readAudit(ctx.fs, ctx.paths.audit)).records, + + subscribe(listener) { + ctx.listeners.add(listener); + return () => void ctx.listeners.delete(listener); + }, + + readLock: async () => (await loadLock(ctx)).lockfile, + }; +} + +export { resolveSource, serializeLockfile }; +export type { PackageInspection, ParsedManifest }; +export { validateManifest, inspectPackage } from "./manifest.js"; +export { computeIntegrity } from "./integrity.js"; +export { parsePolicyText, DEFAULT_POLICY } from "./policy.js"; diff --git a/packages/sdk/src/testing.ts b/packages/sdk/src/testing.ts new file mode 100644 index 0000000..6434e28 --- /dev/null +++ b/packages/sdk/src/testing.ts @@ -0,0 +1,241 @@ +/** + * In-memory StorePorts fakes for tests (and cli previews). Everything is + * a Map keyed by normalized path; symlinks are first-class so integrity + * and materialization tests exercise the real code paths. + */ + +import { FsError } from "./ports.js"; +import type { + ExecOptions, + ExecPort, + ExecResult, + FsDirent, + FsPort, + FsStat, +} from "./ports.js"; +import { normalize } from "./path.js"; +import type { StorePorts } from "./ports.js"; + +type Node = + | { type: "directory"; children: Set } + | { type: "file"; data: Uint8Array; mode: "0644" | "0755"; mtime: number } + | { type: "symlink"; target: string }; + +export interface MemFs extends FsPort { + /** Test helpers — not part of FsPort. */ + putFile(path: string, content: string | Uint8Array): void; + putSymlink(path: string, target: string): void; + tree(): string[]; + clock: { now: number }; +} + +const enc = new TextEncoder(); + +const parentOf = (p: string): string => { + const i = p.lastIndexOf("/"); + return i <= 0 ? "/" : p.slice(0, i); +}; +const leafOf = (p: string): string => p.slice(p.lastIndexOf("/") + 1); + +export const createMemFs = (): MemFs => { + const nodes = new Map(); + const clock = { now: 1_000_000 }; + nodes.set("/", { type: "directory", children: new Set() }); + + const get = (path: string): Node => { + const n = nodes.get(normalize(path)); + if (n === undefined) throw new FsError("not-found", path); + return n; + }; + const getDir = (path: string): Set => { + const n = get(path); + if (n.type !== "directory") throw new FsError("not-directory", path); + return n.children; + }; + const ensureDirChain = (path: string): void => { + const parts = normalize(path).split("/").filter(Boolean); + let cur = ""; + for (const part of parts) { + cur += `/${part}`; + const existing = nodes.get(cur); + if (existing) { + if (existing.type !== "directory") throw new FsError("io", cur, `mkdir on non-dir: ${cur}`); + } else { + nodes.set(cur, { type: "directory", children: new Set() }); + (nodes.get(parentOf(cur)) as Extract).children.add(part); + } + } + }; + const rmNode = (path: string): void => { + const n = get(path); + if (n.type === "directory") + for (const c of [...n.children]) rmNode(`${path}/${c}`); + const parent = nodes.get(parentOf(path)); + if (parent?.type === "directory") parent.children.delete(leafOf(path)); + nodes.delete(path); + }; + + const fs: MemFs = { + clock, + async readFile(path) { + const n = get(path); + if (n.type === "file") return n.data; + if (n.type === "symlink") { + const resolved = normalize(n.target.startsWith("/") ? n.target : `${parentOf(path)}/${n.target}`); + const t = nodes.get(resolved); + if (t?.type === "file") return t.data; + throw new FsError("not-found", resolved); + } + throw new FsError("io", path, `not a file: ${path}`); + }, + async writeFile(path, data) { + const p = normalize(path); + getDir(parentOf(p)); + const dataBuf = typeof data === "string" ? enc.encode(data) : data; + nodes.set(p, { type: "file", data: dataBuf, mode: "0644", mtime: clock.now++ }); + getDir(parentOf(p)).add(leafOf(p)); + }, + async appendFile(path, data) { + const buf = typeof data === "string" ? enc.encode(data) : data; + try { + const cur = await fs.readFile(path); + const merged = new Uint8Array(cur.length + buf.length); + merged.set(cur); + merged.set(buf, cur.length); + await fs.writeFile(path, merged); + } catch { + await fs.writeFile(path, buf); + } + }, + async mkdir(path) { + ensureDirChain(path); + }, + async rename(from, to) { + const f = normalize(from); + const t = normalize(to); + const node = get(f); + getDir(parentOf(t)); + // If destination exists as a dir, refuse (POSIX would rename inside). + const dst = nodes.get(t); + if (dst?.type === "directory") throw new FsError("io", t, `rename target is a dir: ${t}`); + // Move subtree. + if (node.type === "directory") { + const subtree = [...nodes.keys()].filter((k) => k === f || k.startsWith(`${f}/`)); + for (const k of subtree) { + const node2 = nodes.get(k)!; + nodes.delete(k); + nodes.set(k === f ? t : `${t}${k.slice(f.length)}`, node2); + } + } else { + nodes.delete(f); + nodes.set(t, node); + } + (nodes.get(parentOf(f)) as Extract).children.delete(leafOf(f)); + getDir(parentOf(t)).add(leafOf(t)); + }, + async remove(path, _opts) { + const p = normalize(path); + if (!nodes.has(p)) return; // spec: missing paths succeed silently + rmNode(p); + }, + async stat(path) { + const n = nodes.get(normalize(path)); + return n === undefined ? null : statOf(n); + }, + async readDir(path): Promise { + const children = getDir(path); + const out: FsDirent[] = []; + for (const name of children) { + const child = nodes.get(`${normalize(path)}/${name}`)!; + out.push({ name, type: child.type === "directory" ? "directory" : child.type === "symlink" ? "symlink" : "file" }); + } + return out; + }, + async readlink(path) { + const n = get(path); + if (n.type !== "symlink") throw new FsError("io", path, `not a symlink: ${path}`); + return n.target; + }, + async createExclusive(path, data) { + const p = normalize(path); + if (nodes.has(p)) return false; + await fs.writeFile(p, data); + return true; + }, + async symlink(target, linkPath) { + const p = normalize(linkPath); + getDir(parentOf(p)); + nodes.set(p, { type: "symlink", target }); + getDir(parentOf(p)).add(leafOf(p)); + }, + putFile(path, content) { + const p = normalize(path); + ensureDirChain(parentOf(p)); + nodes.set(p, { + type: "file", + data: typeof content === "string" ? enc.encode(content) : content, + mode: "0644", + mtime: clock.now++, + }); + getDir(parentOf(p)).add(leafOf(p)); + }, + putSymlink(path, target) { + const p = normalize(path); + ensureDirChain(parentOf(p)); + nodes.set(p, { type: "symlink", target }); + getDir(parentOf(p)).add(leafOf(p)); + }, + tree() { + return [...nodes.keys()].sort(); + }, + }; + + const statOf = (n: Node): FsStat => + n.type === "file" + ? { type: "file", size: n.data.length, mtimeMs: n.mtime } + : n.type === "directory" + ? { type: "directory", size: 0, mtimeMs: 0 } + : { type: "symlink", size: n.target.length, mtimeMs: 0 }; + + return fs; +}; + +/* ------------------------------ exec fake --------------------------- */ + +export type FakeExecHandler = ( + cmd: string, + args: string[], + opts: ExecOptions, +) => Promise | ExecResult; + +export interface MemExec extends ExecPort { + /** Recorded invocations (deep-ish copies). */ + calls: { cmd: string; args: string[]; opts: ExecOptions }[]; + /** Route a command by prefix: `handler.set("git", fn)` or fixed result. */ + handler: Map; +} + +export const createMemExec = (): MemExec => { + const calls: MemExec["calls"] = []; + const handler = new Map(); + const exec: MemExec = { + calls, + handler, + async run(cmd, args, opts = {}) { + calls.push({ cmd, args: [...(args ?? [])], opts }); + const key = [...handler.keys()].find( + (k) => cmd === k || cmd.endsWith(`/${k}`) || cmd.startsWith(`${k} `), + ); + const h = key === undefined ? undefined : handler.get(key); + if (h === undefined) return { code: 0, stdout: "", stderr: "" }; + if (typeof h === "function") return h(cmd, [...(args ?? [])], opts); + return h; + }, + }; + return exec; +}; + +export const createTestPorts = (): StorePorts & { fs: MemFs; exec: MemExec } => ({ + fs: createMemFs(), + exec: createMemExec(), +}); diff --git a/packages/sdk/src/toml.ts b/packages/sdk/src/toml.ts new file mode 100644 index 0000000..6727ccc --- /dev/null +++ b/packages/sdk/src/toml.ts @@ -0,0 +1,172 @@ +/** + * Minimal TOML reader for `harness/config.toml`. + * + * Supports the subset the spec's `[policy]` and `[[serve.caller]]` tables + * need: sections, dotted keys, strings, integers, floats, booleans and + * arrays of scalars. It is a *reader* — the sdk never writes config.toml + * (policy changes come from the user's editor and are audited when + * observed, per trust.md §4.2). + */ + +type TomlValue = string | number | boolean | TomlValue[] | TomlTable; +interface TomlTable { + [key: string]: TomlValue; +} + +export class TomlParseError extends Error { + readonly line: number; + constructor(line: number, message: string) { + super(`TOML line ${line}: ${message}`); + this.name = "TomlParseError"; + this.line = line; + } +} + +const unescapeBasic = (s: string): string => + s.replace(/\\(u[0-9a-fA-F]{4}|n|t|r|"|\\)/g, (m, esc: string) => { + if (esc.startsWith("u")) return String.fromCharCode(parseInt(esc.slice(1), 16)); + return { n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\" }[esc] ?? m; + }); + +const splitArray = (body: string): string[] => { + const parts: string[] = []; + let depth = 0; + let inStr = false; + let strQuote = ""; + let current = ""; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (inStr) { + current += ch; + if (ch === "\\" && strQuote === '"') current += body[++i] ?? ""; + else if (ch === strQuote) inStr = false; + continue; + } + if (ch === '"' || ch === "'") { + inStr = true; + strQuote = ch; + current += ch; + continue; + } + if (ch === "[") depth++; + if (ch === "]") depth--; + if (ch === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim() !== "") parts.push(current.trim()); + return parts; +}; + +const parseValue = (raw: string, line: number): TomlValue => { + const v = raw.trim(); + if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) + return unescapeBasic(v.slice(1, -1)); + if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) + return v.slice(1, -1); + if (v === "true") return true; + if (v === "false") return false; + if (v.startsWith("[") && v.endsWith("]")) + return splitArray(v.slice(1, -1)).map((p) => parseValue(p, line)); + if (/^[+-]?\d+$/.test(v)) return parseInt(v, 10); + if (/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(v)) return parseFloat(v); + throw new TomlParseError(line, `unsupported value: ${v}`); +}; + +const stripComment = (line: string): string => { + let inStr = false; + let strQuote = ""; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inStr) { + if (ch === "\\" && strQuote === '"') i++; + else if (ch === strQuote) inStr = false; + continue; + } + if (ch === '"' || ch === "'") { + inStr = true; + strQuote = ch; + continue; + } + if (ch === "#") return line.slice(0, i); + } + return line; +}; + +const setPath = (table: TomlTable, keys: string[], value: TomlValue, line: number): void => { + let cur = table; + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i].trim(); + const next = cur[k]; + if (next === undefined) cur[k] = {}; + else if (typeof next !== "object" || Array.isArray(next)) + throw new TomlParseError(line, `key ${k} conflicts with a scalar`); + cur = cur[k] as TomlTable; + } + const leaf = keys[keys.length - 1].trim(); + if (cur[leaf] !== undefined) throw new TomlParseError(line, `duplicate key ${leaf}`); + cur[leaf] = value; +}; + +const getSection = (root: TomlTable, keys: string[], array: boolean, line: number): TomlTable => { + let cur = root; + for (const rawKey of keys) { + const k = rawKey.trim(); + let next = cur[k]; + if (next === undefined) { + next = array && k === keys[keys.length - 1].trim() ? [] : {}; + cur[k] = next; + } + if (Array.isArray(next)) { + if (next.length === 0 || typeof next[next.length - 1] !== "object") + throw new TomlParseError(line, `array ${k} holds non-table values`); + cur = next[next.length - 1] as TomlTable; + } else if (typeof next === "object") { + cur = next as TomlTable; + } else { + throw new TomlParseError(line, `key ${k} conflicts with a scalar`); + } + } + return cur; +}; + +/** Parse a TOML document into a plain object (subset of TOML 1.0). */ +export const parseToml = (text: string): Record => { + const root: TomlTable = {}; + let section = root; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = stripComment(lines[i]).trim(); + if (line === "") continue; + const arraySection = line.match(/^\[\[(.+)\]\]$/); + const sectionMatch = line.match(/^\[(.+)\]$/); + if (arraySection) { + const keys = arraySection[1].split("."); + const parent = getSection(root, keys.slice(0, -1), false, i + 1); + const leaf = keys[keys.length - 1].trim(); + let arr = parent[leaf]; + if (arr === undefined) { + arr = []; + parent[leaf] = arr; + } + if (!Array.isArray(arr)) + throw new TomlParseError(i + 1, `${leaf} is not an array of tables`); + const table: TomlTable = {}; + (arr as TomlValue[]).push(table); + section = table; + continue; + } + if (sectionMatch) { + const keys = sectionMatch[1].split("."); + section = getSection(root, keys, false, i + 1); + continue; + } + const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); + if (!kv) throw new TomlParseError(i + 1, `unrecognized syntax: ${line}`); + setPath(section, kv[1].split("."), parseValue(kv[2], i + 1), i + 1); + } + return root as Record; +}; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 0000000..c201fe1 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,166 @@ +/** + * Pinned shared types — spec/bridge/operations.md §1, verbatim. + * Parallel workstreams (cli, metaharness, bridge clients) code against + * these shapes; do not rename. + */ + +export type ExtensionKind = + | "skill" + | "mcp" + | "plugin" + | "hook" + | "command" + | "agent" + | "rule"; + +export const EXTENSION_KINDS: readonly ExtensionKind[] = [ + "skill", + "mcp", + "plugin", + "hook", + "command", + "agent", + "rule", +]; + +/** Pointer to a plugin.json + version, per spec/manifest.md + spec/lockfile.md. */ +export interface ManifestRef { + /** Package name — `plugin.json` `name` field. */ + name: string; + /** Exact installed version (semver string). */ + version: string; + /** Integrity value as recorded in extensions.lock (e.g. "sha256-…"). Optional in-flight; always present server-side. */ + integrity?: string; +} + +/** One installed unit in the store. */ +export interface Extension { + /** Stable id: "@" — also the extensions.lock key. */ + id: string; + /** Primary kind (how it was installed). */ + kind: ExtensionKind; + manifest: ManifestRef; + enabled: boolean; + /** All kinds of content this extension contributes — a `plugin` may provide hooks+commands+skills. */ + provides?: ExtensionKind[]; + /** Whether the negotiated client can consume it (absent = true). */ + supported?: boolean; +} + +/** Host-declared feature surface — see spec/bridge/capabilities.md for slot semantics. */ +export interface Capabilities { + /** ExtensionKind values this client can consume. */ + kinds: ExtensionKind[]; + /** Hook lifecycle events this client will invoke. */ + hookEvents: string[]; + /** Host-injected environment slots. */ + slots: { + storage: "fs" | "kv" | "none"; + secrets: "host" | "prompt" | "none"; + exec: boolean; + skills: "read-write" | "read" | "none"; + mcp: "managed" | "external" | "none"; + [slot: string]: unknown; + }; + experimental?: Record; +} + +/* ------------------------------------------------------------------ */ +/* Lockfile mirror types (spec/lockfile.md §3) */ +/* ------------------------------------------------------------------ */ + +export type SourceType = "git" | "github" | "registry" | "local"; + +/** Lockfile `source` object (lockfile.md §3.3). */ +export interface SourceRef { + type: SourceType; + /** Canonical identifier: clone URL / `owner/repo` / index coordinates / path. */ + uri: string; + /** Resolved revision (commit SHA, tag, registry version). */ + ref?: string; + /** Subpath within the source holding the package (monorepo sources). */ + path?: string; + /** The ref spelling the caller asked for, when `ref` was resolved from it. */ + requestedRef?: string; +} + +export interface ComponentRef { + kind: ExtensionKind; + name: string; +} + +/** + * One `extensions.lock` entry. `enabled` is an additive field the schema + * does not yet declare — see the session report (spec gap: the bridge's + * `Extension.enabled` requires a persisted home). + */ +export interface LockEntry { + kind: ExtensionKind; + manifest: ManifestRef; + source: SourceRef; + integrity: string; + treeHash?: string; + installedAt: string; + updatedAt: string; + targets: string[]; + components?: ComponentRef[]; + capabilities?: string[]; + attestations?: object[]; + enabled?: boolean; + [unknownField: string]: unknown; // forward-compat, preserved on rewrite +} + +export interface Lockfile { + $schema?: string; + version: number; + extensions: Record; + [unknownField: string]: unknown; +} + +/* ------------------------------------------------------------------ */ +/* Trust (spec/trust.md §4, §6) */ +/* ------------------------------------------------------------------ */ + +export type Actor = "user" | "agent" | "daemon"; + +export type ExecClass = "setup" | "hooks" | "mcp" | "skillScripts"; + +export type PolicyDecision = "allow" | "deny" | "ask"; + +export interface TrustPolicy { + exec: { + setup: PolicyDecision; + hooks: PolicyDecision; + mcp: PolicyDecision; + skillScripts: PolicyDecision; + /** What `ask` resolves to when no human can answer. */ + nonInteractive: "deny" | "allow"; + }; + sources: { + allow: string[]; + deny: string[]; + }; +} + +export type AuditEvent = + | "install" + | "update" + | "remove" + | "integrity.verify" + | "integrity.fail" + | "exec.allow" + | "exec.deny" + | "policy.change" + | "lockfile.corrupt" + | "skills.conflict"; + +export interface AuditRecord { + ts: string; + event: AuditEvent; + actor: Actor; + extension?: ManifestRef; + decision?: PolicyDecision; + integrity?: string; + source?: SourceRef; + details?: Record; +}