From b2dc08b376f1394c8e9deb78a0d6e4a2a8609983 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:20:40 +0000 Subject: [PATCH] fix(cli): reconcile W9 against the merged sdk store surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W8 landed with the canonical store API; the cli was pinned against the pre-merge sketch. Reconciles to the sdk as shipped: - api.ts is now a type-only re-export hub of the sdk surface + the cli-local bridge constants (drift-proof by construction) - ports/fs.ts: byte-oriented ops, .code-carrying errors (ENOENT→not-found etc.), stat→null on missing, createExclusive via O_EXCL, readlink + ln-backed symlink - ports/exec.ts: run(command, args, opts) → {code,stdout,stderr} over execFile only - root.ts resolves the AGENTS ROOT (~/.agents), not the harness dir — the sdk owns harness/ beneath it - createStore gets {actor, approveExec}; TTY sessions answer approveExec via a y/N prompt per exec class - add maps --skill-target to installTarget + --update; list forwards kinds/enabledOnly; audit reads store.auditLog(); serve reads harness/config.toml under the store root - toCliError forwards StoreError.kind + data instead of flattening every store failure to "internal" - testing layer rewritten to the canonical shapes (memory FsPort, full-surface mock Store); all test fixtures updated - sdk: drop the declare-global block (collides with @types/node at TS2300), take TextEncoder/URL et al. from lib DOM instead; tsdown alwaysBundle sdk so dist/cli.js runs standalone for scriptc Verified end-to-end: add/list/verify/disable/enable/audit/doctor/remove on a fresh store + stdio bridge handshake with capability-gated extensions.list. --- packages/cli/src/api.ts | 253 +++++++------------------ packages/cli/src/cli.ts | 28 ++- packages/cli/src/commands.test.ts | 29 ++- packages/cli/src/commands/add.ts | 16 +- packages/cli/src/commands/audit.ts | 32 +--- packages/cli/src/commands/list.ts | 16 +- packages/cli/src/commands/serve.ts | 9 +- packages/cli/src/commands/verify.ts | 2 +- packages/cli/src/deps.ts | 2 +- packages/cli/src/errors.ts | 13 ++ packages/cli/src/ports.test.ts | 66 ++++--- packages/cli/src/ports/exec.ts | 44 ++--- packages/cli/src/ports/fs.ts | 189 +++++++++++------- packages/cli/src/root.ts | 9 +- packages/cli/src/testing/memory-fs.ts | 72 ++++--- packages/cli/src/testing/mock-store.ts | 164 ++++++++++++++-- packages/cli/tsdown.config.ts | 5 +- packages/sdk/src/ports.ts | 28 +-- packages/sdk/tsconfig.json | 3 +- 19 files changed, 530 insertions(+), 450 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 52bc057..8b1da75 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -1,194 +1,67 @@ /** - * Pinned cross-workstream API surface for `@any-harness/sdk`. + * Cli-facing view of the `@any-harness/sdk` surface. * - * These names are the Wave-2 interface pin from - * `.agents/plans/2026-10-07-v2-foundation.md`: both the sdk (W8) and the cli - * (W9) code against them and drift is reconciled at merge. The method-level - * shapes below are the cli-side reading of that pin — the canonical - * definitions land with the sdk; until then `sdk-bind.ts` casts through - * `unknown` so this file is the single point of drift. + * Wave-2 reconciliation: W8 landed the canonical types in `packages/sdk` + * — this file now re-exports them type-only (erased at compile time, so + * `sdk-bind.ts` stays the ONE runtime importer). Anything the cli needs + * that the sdk does not export stays defined here. * - * Bridge wire types (`Extension`, `ExtensionKind`, `ManifestRef`) are pinned - * verbatim by `spec/bridge/operations.md` §1. + * Bridge wire types (`Extension`, `ExtensionKind`, `ManifestRef`) are + * pinned verbatim by `spec/bridge/operations.md` §1 — the sdk owns them. */ -export type ExtensionKind = - | "skill" - | "mcp" - | "plugin" - | "hook" - | "command" - | "agent" - | "rule"; - -/** Pointer to a plugin.json + version, per spec/manifest.md + spec/lockfile.md. */ -export interface ManifestRef { - name: string; - version: string; - /** Integrity value as recorded in extensions.lock (e.g. "sha256-…"). */ - integrity?: string; -} - -/** One installed unit in the store (spec/bridge/operations.md §1). */ -export interface Extension { - /** Stable id: "@" — also the extensions.lock key. */ - id: string; - kind: ExtensionKind; - manifest: ManifestRef; - enabled: boolean; - provides?: ExtensionKind[]; - supported?: boolean; -} - -/** Source coordinates produced by `resolveSource` (spec/lockfile.md §3.3). */ -export interface SourceRef { - type: "git" | "github" | "registry" | "local"; - /** Canonical source identifier for its type. */ - uri: string; - /** Floating ref to pin at install (branch/tag); resolved to a SHA by install. */ - ref?: string; - /** Subpath within the source where the package lives (monorepo sources). */ - path?: string; -} - -/** Who invoked a mutating operation (spec/trust.md §4.2 audit `actor`). */ -export type Actor = "user" | "agent" | "daemon"; - -export interface InstallOptions { - /** Materialization target for skill-kind components (store-layout.md §5.1). */ - skillTarget?: "shared" | "store"; - /** Audit actor classification; the store records it on trust events. */ - actor?: Actor; -} - -export interface VerifyResult { - ok: boolean; - /** Integrity recorded in extensions.lock. */ - expected?: string; - /** Integrity recomputed over the installed tree. */ - actual?: string; - details?: string; -} - -export interface DoctorFinding { - severity: "info" | "warn" | "error"; - /** Stable kebab-case finding code (e.g. "lock-corrupt", "orphan-package"). */ - code: string; - message: string; - path?: string; - extension?: string; -} - -export interface DoctorReport { - ok: boolean; - findings: DoctorFinding[]; -} - -/** The store object `createStore` returns (pinned method names). */ -export interface Store { - list(): Promise; - install(source: SourceRef, opts?: InstallOptions): Promise; - remove(name: string): Promise; - setEnabled(name: string, enabled: boolean): Promise; - materialize(name: string): Promise<{ materializedTo: string[] }>; - verify(name: string): Promise; - doctor(): Promise; -} - -/** - * Filesystem port — the only fs the sdk store ever sees. Implemented over - * `node:fs` here; the sdk ships an in-memory implementation for tests and - * browser/kv hosts provide their own. - * - * Member-level shape is provisional pending W8: names chosen to cover the - * L0 requirements (atomic staged writes, lockfile/digest reads, skills/ - * materialization, symlink-with-copy-fallback). - */ -export interface FsPort { - /** UTF-8 file contents; throws `not-found` when absent. */ - readFile(path: string): Promise; - /** Raw bytes for digest computation (spec/lockfile.md §4). */ - readFileBytes(path: string): Promise; - /** Create parent dirs and write; implementations SHOULD write atomically - * (sibling temp + rename per store-layout.md §7.1). */ - writeFile(path: string, data: string | Uint8Array): Promise; - /** Append UTF-8 bytes (audit.log `O_APPEND` semantics). */ - appendFile(path: string, data: string): Promise; - exists(path: string): Promise; - stat(path: string): Promise<{ - kind: "file" | "directory" | "symlink" | "other"; - size: number; - mtimeMs: number; - }>; - /** Entry names (not full paths) inside a directory; throws when absent. */ - list(path: string): Promise; - /** `mkdir -p` semantics; no error when already present. */ - mkdir(path: string): Promise; - /** Recursive remove; no error when absent. */ - remove(path: string): Promise; - rename(from: string, to: string): Promise; - /** - * Create `path` as a symlink to `target`. Implementations without symlink - * support (scriptc island: no `symlinkSync`) MAY throw; callers fall back - * to copying per the `ln`+copy rule in AGENTS.md §7. - */ - symlink?(target: string, path: string): Promise; - readlink?(path: string): Promise; - /** Recursive copy of a file or directory tree (symlink fallback path). */ - copy(from: string, to: string): Promise; -} - -export interface ExecResult { - code: number; - stdout: string; - stderr: string; -} - -export interface ExecOptions { - cwd?: string; - env?: Record; - /** Kill the process after this many ms; result reports a non-zero code. */ - timeoutMs?: number; -} - -/** - * Process port — git ops shell out to the `git` binary per AGENTS.md §7; - * `exec` takes one executable token + argv (no shell), `run` is the - * shell-string convenience form for trusted, internally-built commands. - */ -export interface ExecPort { - exec(file: string, args?: string[], opts?: ExecOptions): Promise; - run(command: string, opts?: ExecOptions): Promise; -} - -/** Pinned: `createStore(root, ports)` — the host injects both ports. */ -export interface StorePorts { - fs: FsPort; - exec: ExecPort; -} - -/* ── JSON-RPC envelope (spec/bridge/protocol.md §2) ─────────────────── */ - -export interface JsonRpcRequest { - jsonrpc: "2.0"; - /** Absent on notifications. */ - id?: string | number; - method: string; - params?: Record; -} - -export interface JsonRpcErrorBody { - code: number; - message: string; - data?: Record; -} - -export interface JsonRpcResponse { - jsonrpc: "2.0"; - id: string | number | null; - result?: unknown; - error?: JsonRpcErrorBody; -} +// Re-exported for cli consumers (type-only — erased, so sdk-bind stays +// the single runtime importer). +export type { + Actor, + ApproveExec, + AuditEvent, + AuditRecord, + DoctorFinding, + DoctorReport, + ExecOptions, + ExecPort, + ExecResult, + Extension, + ExtensionKind, + FsDirent, + FsPort, + FsStat, + InstallOptions, + InstallResult, + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, + ListOptions, + ManifestRef, + MaterializeOptions, + MaterializeResult, + SourceRef, + Store, + StoreEntry, + StoreNotification, + StoreOptions, + StorePaths, + StorePorts, + TrustPolicy, + VerifyResult, +} from "@any-harness/sdk"; + +// Local aliases for use in this file's own declarations (re-exports do +// not bind names in-module). +import type { + JsonRpcRequest, + JsonRpcResponse, + SourceRef, + Store, + StoreOptions, + StorePorts, +} from "@any-harness/sdk"; + +/** Alias kept for the cli's envelope terminology (sdk name: JsonRpcError). */ +export type { JsonRpcError as JsonRpcErrorBody } from "@any-harness/sdk"; + +/* ── cli-local constants (pinned by spec/bridge/operations.md) ──────── */ /** Pinned bridge ops (spec/bridge/operations.md). */ export const BRIDGE_METHODS = [ @@ -210,9 +83,13 @@ export const ALWAYS_ALLOWED_METHODS: ReadonlySet = new Set([ "events.notify", ]); -/** The full pinned sdk surface the cli binds to (see sdk-bind.ts). */ +/** The pinned sdk surface the cli binds to (see sdk-bind.ts). */ export interface SdkApi { - createStore(root: string, ports: StorePorts): Store; + createStore( + root: string, + ports: StorePorts, + options?: StoreOptions, + ): Store; resolveSource(input: string): SourceRef; handleBridgeRequest( store: Store, diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 40dfe3c..afceca1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -23,11 +23,12 @@ import { createLogger, emitError, stdoutWriters } from "./output.js"; import { createNodePorts } from "./ports/index.js"; import { resolveStoreRoot } from "./root.js"; import { sdkApi } from "./sdk-bind.js"; +import type { ApproveExec } from "./api.js"; const USAGE = `harness — AnyHarness package manager + bridge server usage: - harness add [--skill-target shared|store] [-y] [--json] + harness add [--skill-target shared|store] [--update] [-y] [--json] harness remove [-y] [--json] harness list [--all] [--kinds skill,mcp] [--json] harness enable [--json] @@ -38,8 +39,8 @@ usage: harness serve [--transport stdio] global flags: - --root store root (default: $ANYHARNESS_STORE, - $ANYHARNESS_HOME/harness, or ~/.agents/harness) + --root agents root (default: $ANYHARNESS_STORE, + $ANYHARNESS_HOME, or ~/.agents) --json machine-readable output on stdout -h, --help this text --version print version @@ -139,8 +140,22 @@ const main = async (): Promise => { process.env.HOME ?? "", flagString(args.flags, "root"), ); + const actor = + command === "serve" + ? ("daemon" as const) + : detectActor(process.env, interactive); const deps: CliDeps = { - store: sdkApi.createStore(storeRoot, ports), + store: sdkApi.createStore(storeRoot, ports, { + actor, + approveExec: interactive + ? async (req: Parameters[0]) => { + const ask = ttyConfirm((s) => w.err(s)); + return ask( + `allow ${req.execClass} exec from ${req.extension.name}@${req.extension.version}: ${req.command} ${req.args.join(" ")} (${req.reason})?`, + ); + } + : undefined, + }), resolveSource: sdkApi.resolveSource, handleBridgeRequest: sdkApi.handleBridgeRequest, fs: ports.fs, @@ -149,10 +164,7 @@ const main = async (): Promise => { env: process.env, storeRoot, interactive, - actor: - command === "serve" - ? "daemon" - : detectActor(process.env, interactive), + actor, confirm: interactive ? ttyConfirm((s) => w.err(s)) : undefined, setExitCode: (code) => { process.exitCode = code; diff --git a/packages/cli/src/commands.test.ts b/packages/cli/src/commands.test.ts index c8178c5..fda8aa6 100644 --- a/packages/cli/src/commands.test.ts +++ b/packages/cli/src/commands.test.ts @@ -9,7 +9,6 @@ import { cmdList } from "./commands/list.js"; import { cmdRemove } from "./commands/remove.js"; import { cmdVerify } from "./commands/verify.js"; import { CliError } from "./errors.js"; -import { createMemoryFs } from "./testing/memory-fs.js"; import { createMockStore } from "./testing/mock-store.js"; import { createTestDeps } from "./testing/deps.js"; import type { Extension } from "./api.js"; @@ -42,7 +41,8 @@ describe("harness add", () => { parseArgs(["acme/tools", "-y", "--skill-target", "store"]), ); expect(t.store.installs[0].opts).toEqual({ - skillTarget: "store", + installTarget: "packages", + update: undefined, actor: "agent", }); }); @@ -177,16 +177,15 @@ describe("harness doctor", () => { }); describe("harness audit", () => { - const LOG = [ - JSON.stringify({ ts: "t1", event: "install", actor: "user", extension: { name: "a", version: "1" } }), - '{"broken":', // partial line — skipped - JSON.stringify({ ts: "t2", event: "exec.deny", actor: "agent", extension: { name: "b", version: "2" }, decision: "deny" }), - ].join("\n"); - - it("reads audit.log via the fs port, skipping partial lines", async () => { - const fs = createMemoryFs({ "/test/.agents/harness/audit.log": LOG }); - const t = createTestDeps(); - t.deps.fs = fs; + const EVENTS = [ + { ts: "t1", event: "install", actor: "user", extension: { name: "a", version: "1" } }, + { ts: "t2", event: "exec.deny", actor: "agent", extension: { name: "b", version: "2" }, decision: "deny" }, + ] as const; + + it("reads audit records via the store, skipping partial lines", async () => { + const store = createMockStore(); + store.audit.push(...EVENTS); + const t = createTestDeps({ store }); await cmdAudit(t.deps, parseArgs(["--json"])); const out = JSON.parse(t.out[0]); expect(out.events).toHaveLength(2); @@ -194,9 +193,9 @@ describe("harness audit", () => { }); it("filters by --event and --limit", async () => { - const fs = createMemoryFs({ "/test/.agents/harness/audit.log": LOG }); - const t = createTestDeps(); - t.deps.fs = fs; + const store = createMockStore(); + store.audit.push(...EVENTS); + const t = createTestDeps({ store }); await cmdAudit(t.deps, parseArgs(["--event", "exec.deny", "--json"])); const out = JSON.parse(t.out[0]); expect(out.events).toHaveLength(1); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 13feaf4..337d0a1 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -4,7 +4,9 @@ * Source resolution and the install itself are sdk operations; the cli * owns the flag surface and the trust discipline around them: * - `--skill-target shared|store` picks the skill materialization root - * (shared `~/.agents/skills/` is the spec default — store-layout §5.1) + * (shared `~/.agents/skills/` is the spec default — store-layout §5.1; + * `store` maps to the sdk's `installTarget: "packages"`) + * - `--update` allows replacing an existing lock entry (trust.md §3.3) * - `-y` attests the mutating op; without it an interactive TTY gets a * y/N prompt and a non-interactive caller is refused * - the resolved `actor` rides along so the sdk's trust layer can keep @@ -21,7 +23,7 @@ import { import { confirm } from "../confirm.js"; import { emit, table } from "../output.js"; -const KNOWN = ["skill-target", "y", "yes", "json"] as const; +const KNOWN = ["skill-target", "update", "y", "yes", "json"] as const; export const cmdAdd = async (deps: CliDeps, args: ParsedArgs): Promise => { assertNoUnknownFlags(args.flags, KNOWN); @@ -36,22 +38,26 @@ export const cmdAdd = async (deps: CliDeps, args: ParsedArgs): Promise => const source = deps.resolveSource(sourceInput); await confirm(deps, `install ${source.type} source ${source.uri}?`, { yes }); - const extension = await deps.store.install(source, { - skillTarget, + const result = await deps.store.install(source, { + installTarget: skillTarget === "store" ? "packages" : "shared", + update: flagBool(args.flags, "update") || undefined, actor: deps.actor, }); + const extension = result.extension; emit( deps.w, - { installed: extension }, + { installed: extension, location: result.location, warnings: result.warnings }, () => table([ ["installed", extension.id], ["kind", extension.kind], + ["location", result.location], [ "provides", extension.provides?.length ? extension.provides.join(",") : "-", ], + ...result.warnings.map((w): [string, string] => ["warning", w]), ]), json, ); diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index 2e6192c..fb7717b 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -2,11 +2,11 @@ * `harness audit` — read the trust audit log. * * `~/.agents/harness/audit.log` is append-only JSONL (trust.md §6.1). The - * pinned Store surface has no audit op, so the cli reads it directly - * through the fs port — read-only, tolerant of the partial last line a - * crashed writer may leave (§6.1: skip and continue). + * store owns it (`Store.auditLog()` — torn-line tolerant per §6.1); the + * cli owns filtering and presentation. */ import type { CliDeps } from "../deps.js"; +import type { AuditRecord } from "../api.js"; import { assertNoUnknownFlags, flagBool, @@ -17,24 +17,14 @@ import { emit, table } from "../output.js"; const KNOWN = ["json", "limit", "event", "actor", "extension"] as const; -export interface AuditEvent { - ts: string; - event: string; - actor: string; - extension?: { name: string; version: string }; - decision?: string; - integrity?: string; - source?: Record; - details?: Record; -} - -export const parseAuditLog = (text: string): AuditEvent[] => { - const events: AuditEvent[] = []; +/** Standalone JSONL parser — tolerant of a torn trailing line (§6.1). */ +export const parseAuditLog = (text: string): AuditRecord[] => { + const events: AuditRecord[] = []; for (const line of text.split("\n")) { const trimmed = line.trim(); if (trimmed === "") continue; try { - const parsed = JSON.parse(trimmed) as AuditEvent; + const parsed = JSON.parse(trimmed) as AuditRecord; if (typeof parsed.ts === "string" && typeof parsed.event === "string") { events.push(parsed); } @@ -57,15 +47,11 @@ export const cmdAudit = async ( const limitRaw = flagString(args.flags, "limit"); const limit = limitRaw === undefined ? 50 : Number.parseInt(limitRaw, 10); - const path = `${deps.storeRoot}/audit.log`; - const exists = await deps.fs.exists(path); - if (!exists) { + let events = await deps.store.auditLog(); + if (events.length === 0) { emit(deps.w, { events: [] }, () => "no audit log yet", json); return; } - - const text = await deps.fs.readFile(path); - let events = parseAuditLog(text); if (eventFilter) events = events.filter((e) => e.event === eventFilter); if (actorFilter) events = events.filter((e) => e.actor === actorFilter); if (extensionFilter) { diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index de0a86f..a4d5e8e 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,4 +1,5 @@ import type { CliDeps } from "../deps.js"; +import type { ExtensionKind } from "../api.js"; import { assertNoUnknownFlags, flagBool, @@ -18,18 +19,13 @@ export const cmdList = async ( const kindsRaw = args.flags.kinds; const kinds = typeof kindsRaw === "string" - ? kindsRaw.split(",").map((k) => k.trim()) + ? (kindsRaw.split(",").map((k) => k.trim()) as ExtensionKind[]) : undefined; - let extensions = await deps.store.list(); - if (!all) extensions = extensions.filter((e) => e.enabled); - if (kinds) { - extensions = extensions.filter( - (e) => - kinds.includes(e.kind) || - e.provides?.some((p) => kinds.includes(p)) === true, - ); - } + const extensions = await deps.store.list({ + kinds, + enabledOnly: all ? undefined : true, + }); emit( deps.w, diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4650b8d..aaa963a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -32,11 +32,14 @@ export const cmdServe = async ( throw new CliError("usage", `unsupported transport "${transport}"`); } - const configPath = `${deps.storeRoot}/config.toml`; + // config.toml lives inside the store's `harness/` dir (store-layout §3). + const configPath = `${deps.storeRoot}/harness/config.toml`; let policy = defaultPolicy(); let callers: ReturnType = []; - if (await deps.fs.exists(configPath)) { - const root = parseToml(await deps.fs.readFile(configPath)); + if ((await deps.fs.stat(configPath)) !== null) { + const root = parseToml( + new TextDecoder().decode(await deps.fs.readFile(configPath)), + ); policy = policyFromToml(root); callers = callersFromToml(root); } diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index e2ab99b..b2782d2 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -36,7 +36,7 @@ export const cmdVerify = async ( // trust.md §3.2 — mismatch means untrusted; remediation is reinstall. throw new CliError( "trust-violation", - `integrity mismatch for "${name}": reinstall with \`harness add --reinstall\` (expected ${result.expected ?? "?"}, got ${result.actual ?? "?"})`, + `integrity mismatch for "${name}": reinstall with \`harness add --update\` (expected ${result.expected ?? "?"}, got ${result.actual ?? "?"})`, { details: { expected: result.expected, actual: result.actual } }, ); } diff --git a/packages/cli/src/deps.ts b/packages/cli/src/deps.ts index d2272f1..1936826 100644 --- a/packages/cli/src/deps.ts +++ b/packages/cli/src/deps.ts @@ -25,7 +25,7 @@ export interface CliDeps { w: OutWriters; log: Logger; env: Record; - /** Resolved `~/.agents/harness` directory. */ + /** Resolved agents root (`~/.agents`); the sdk owns `harness/` beneath it. */ storeRoot: string; /** stdin is an interactive TTY (confirm prompts allowed). */ interactive: boolean; diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 308b57f..b1e06b9 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -39,5 +39,18 @@ const exitCodeForKind = (kind: string): number => KIND_EXIT[kind] ?? 1; export const toCliError = (err: unknown): CliError => { if (err instanceof CliError) return err; const message = err instanceof Error ? err.message : String(err); + // sdk StoreErrors carry a spec-defined kind + data; forward both so the + // operator sees e.g. "trust-violation" rather than "internal". Structural + // check, not instanceof — the sdk is a separate package boundary. + if ( + typeof err === "object" && + err !== null && + (err as { name?: string }).name === "StoreError" + ) { + const se = err as { kind?: string; data?: Record }; + return new CliError(se.kind ?? "internal", message, { + details: se.data, + }); + } return new CliError("internal", message); }; diff --git a/packages/cli/src/ports.test.ts b/packages/cli/src/ports.test.ts index c7df6fb..4f283c3 100644 --- a/packages/cli/src/ports.test.ts +++ b/packages/cli/src/ports.test.ts @@ -15,28 +15,31 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); +const text = (b: Uint8Array) => new TextDecoder().decode(b); +const bytes = (s: string) => new TextEncoder().encode(s); + describe("ExecPort", () => { const exec = createExecPort(); - it("exec runs one token + argv (no shell)", async () => { - const res = await exec.exec("echo", ["hello"]); + it("run spawns one token + argv (no shell)", async () => { + const res = await exec.run("echo", ["hello"]); expect(res.code).toBe(0); expect(res.stdout.trim()).toBe("hello"); }); - it("exec surfaces non-zero exits as results, not throws", async () => { - const res = await exec.exec("false"); + it("run surfaces non-zero exits as results, not throws", async () => { + const res = await exec.run("false"); expect(res.code).not.toBe(0); }); it("the git binary is reachable (island rule: git via exec)", async () => { - const res = await exec.exec("git", ["--version"]); + const res = await exec.run("git", ["--version"]); expect(res.code).toBe(0); expect(res.stdout).toContain("git version"); }); it("run honors cwd", async () => { - const res = await exec.run("pwd", { cwd: dir }); + const res = await exec.run("pwd", [], { cwd: dir }); expect(res.stdout.trim()).toBe(dir); }); }); @@ -45,43 +48,54 @@ describe("FsPort", () => { const exec = createExecPort(); const fs = createFsPort(exec); - it("writes atomically and reads back", async () => { + it("writes bytes and reads back", async () => { const p = join(dir, "a", "b.txt"); - await fs.writeFile(p, "hello"); - expect(await fs.readFile(p)).toBe("hello"); + await fs.writeFile(p, bytes("hello")); + expect(text(await fs.readFile(p))).toBe("hello"); const s = await stat(p); expect(s.isFile()).toBe(true); }); it("appendFile appends", async () => { const p = join(dir, "log.txt"); - await fs.appendFile(p, "one\n"); - await fs.appendFile(p, "two\n"); - expect(await fs.readFile(p)).toBe("one\ntwo\n"); + await fs.appendFile(p, bytes("one\n")); + await fs.appendFile(p, bytes("two\n")); + expect(text(await fs.readFile(p))).toBe("one\ntwo\n"); }); - it("exists/stat/list/mkdir/remove", async () => { + it("stat returns null on missing; readDir lists dirents", async () => { const sub = join(dir, "pkg"); - expect(await fs.exists(sub)).toBe(false); + expect(await fs.stat(sub)).toBeNull(); await fs.mkdir(sub); - await fs.writeFile(join(sub, "plugin.json"), "{}"); - expect(await fs.exists(sub)).toBe(true); - expect((await fs.stat(sub)).kind).toBe("directory"); - expect((await fs.stat(join(sub, "plugin.json"))).kind).toBe("file"); - expect(await fs.list(sub)).toEqual(["plugin.json"]); + await fs.writeFile(join(sub, "plugin.json"), bytes("{}")); + expect((await fs.stat(sub))?.type).toBe("directory"); + expect((await fs.stat(join(sub, "plugin.json")))?.type).toBe("file"); + expect(await fs.readDir(sub)).toEqual([ + { name: "plugin.json", type: "file" }, + ]); await fs.remove(sub); - expect(await fs.exists(sub)).toBe(false); + expect(await fs.stat(sub)).toBeNull(); + }); + + it("createExclusive wins once then yields", async () => { + const p = join(dir, ".lock"); + expect(await fs.createExclusive(p, bytes("x"))).toBe(true); + expect(await fs.createExclusive(p, bytes("y"))).toBe(false); + expect(text(await fs.readFile(p))).toBe("x"); + }); + + it("errors carry a .code field the store can inspect", async () => { + await expect(fs.readFile(join(dir, "nope"))).rejects.toMatchObject({ + code: "not-found", + }); }); - it("symlink via ln creates a real link; copy is the fallback", async () => { + it("symlink via ln creates a real link", async () => { const target = join(dir, "target.txt"); const link = join(dir, "link.txt"); - await fs.writeFile(target, "x"); + await fs.writeFile(target, bytes("x")); await fs.symlink!(target, link); - expect(await fs.readlink!(link)).toBe(target); + expect(await fs.readlink(link)).toBe(target); expect(await readFile(link, "utf8")).toBe("x"); - const copied = join(dir, "copied.txt"); - await fs.copy(target, copied); - expect(await fs.readFile(copied)).toBe("x"); }); }); diff --git a/packages/cli/src/ports/exec.ts b/packages/cli/src/ports/exec.ts index fe86f22..45f30de 100644 --- a/packages/cli/src/ports/exec.ts +++ b/packages/cli/src/ports/exec.ts @@ -1,17 +1,17 @@ /** - * Node ExecPort — `node:child_process` under the island rules: `exec` - * spawns one executable token + argv with no shell (this is how git ops - * reach the `git` binary); `run` is the shell form for internally-built - * commands only. Non-zero exits surface as `{code, stderr}` results, - * never as thrown errors — a failing git probe is data, not a crash. + * Node ExecPort — `node:child_process` under the island rules: one + * executable token + argv, never a shell string (canonical shape: + * `packages/sdk/src/ports.ts` — `run(command, args, opts)`). This is how + * git ops reach the `git` binary and how `ln` backs `fs.symlink`. + * Non-zero exits surface as `{code, stderr}` results, never throws — a + * failing git probe is data, not a crash. */ -import { execFile, exec as execShell } from "node:child_process"; +import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { ExecOptions, ExecPort, ExecResult } from "../api.js"; const pExecFile = promisify(execFile); -const pExec = promisify(execShell); interface ChildError { code?: number | string | null; @@ -19,13 +19,6 @@ interface ChildError { stderr?: string; } -const childOptions = (options: ExecOptions, maxBuffer: number) => ({ - cwd: options.cwd, - env: options.env ? { ...process.env, ...options.env } : undefined, - timeout: options.timeoutMs, - maxBuffer, -}); - const settle = async ( promise: Promise<{ stdout: string | Buffer; stderr: string | Buffer }>, ): Promise => { @@ -47,20 +40,17 @@ export const createExecPort = (opts?: { }): ExecPort => { const maxBuffer = opts?.maxBuffer ?? 16 * 1024 * 1024; return { - exec: (file, args = [], options: ExecOptions = {}) => - settle( - pExecFile(file, args, childOptions(options, maxBuffer)) as Promise<{ - stdout: string; - stderr: string; - }>, - ), - - run: (command, options: ExecOptions = {}) => + // `options.stdin` is currently unimplemented (execFile has no stdin + // channel); no sdk call site needs it yet — spawn-backed stdin lands + // when a caller does. + run: (command, args = [], options: ExecOptions = {}) => settle( - pExec(command, childOptions(options, maxBuffer)) as Promise<{ - stdout: string; - stderr: string; - }>, + pExecFile(command, args, { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : undefined, + timeout: options.timeoutMs, + maxBuffer, + }) as Promise<{ stdout: string; stderr: string }>, ), }; }; diff --git a/packages/cli/src/ports/fs.ts b/packages/cli/src/ports/fs.ts index 2a3ed3a..32b72a3 100644 --- a/packages/cli/src/ports/fs.ts +++ b/packages/cli/src/ports/fs.ts @@ -1,13 +1,16 @@ /** - * Node FsPort — the only fs implementation the cli injects into the sdk - * store. Writes are atomic (sibling temp + rename, store-layout.md §7.1); - * symlink goes through `ln` with a copy fallback per the scriptc-island - * rule (no `symlinkSync` — AGENTS.md §7). + * Node FsPort — the fs implementation the cli injects into the sdk store + * (canonical shape: `packages/sdk/src/ports.ts`). Atomicity primitives the + * store relies on: `rename` for staged writes, `createExclusive` for the + * `.lock` mutex (store-layout.md §7). `symlink` goes through `ln` with the + * caller's copy fallback per the scriptc-island rule (AGENTS.md §7). + * + * Errors are thrown with a `.code` field matching sdk `FsError.code` — + * the store inspects `.code` (never `instanceof`), so a local class keeps + * `sdk-bind.ts` the only runtime importer. */ import { appendFile as fsAppendFile, - copyFile, - cp, lstat, mkdir as fsMkdir, readFile as fsReadFile, @@ -15,19 +18,69 @@ import { readlink as fsReadlink, rename as fsRename, rm, - stat as fsStat, writeFile as fsWriteFile, } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { randomBytes } from "node:crypto"; +import { dirname } from "node:path"; -import type { ExecPort, FsPort } from "../api.js"; +import type { + ExecPort, + FsDirent, + FsPort, + FsStat, +} from "../api.js"; -const kindOf = (mode: { +type FsCode = + | "not-found" + | "exists" + | "not-directory" + | "is-directory" + | "not-empty" + | "io"; + +/** FsError-shaped error (sdk ports.ts): the store keys on `.code` only. */ +class PortFsError extends Error { + readonly code: FsCode; + readonly path: string; + constructor(code: FsCode, path: string, message?: string) { + super(message ?? `${code}: ${path}`); + this.name = "FsError"; + this.code = code; + this.path = path; + } +} + +const codeOf = (err: unknown): FsCode => { + const c = (err as { code?: string }).code; + switch (c) { + case "ENOENT": + return "not-found"; + case "EEXIST": + return "exists"; + case "ENOTDIR": + return "not-directory"; + case "EISDIR": + return "is-directory"; + case "ENOTEMPTY": + return "not-empty"; + default: + return "io"; + } +}; + +const wrap = async (path: string, op: () => Promise): Promise => { + try { + return await op(); + } catch (err) { + if (err instanceof PortFsError) throw err; + throw new PortFsError(codeOf(err), path, (err as Error).message); + } +}; + +const typeOf = (mode: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; -}): "file" | "directory" | "symlink" | "other" => { +}): FsStat["type"] => { if (mode.isSymbolicLink()) return "symlink"; if (mode.isFile()) return "file"; if (mode.isDirectory()) return "directory"; @@ -35,70 +88,74 @@ const kindOf = (mode: { }; export const createFsPort = (exec: ExecPort): FsPort => ({ - readFile: (path) => fsReadFile(path, "utf8"), - - readFileBytes: async (path) => new Uint8Array(await fsReadFile(path)), - - writeFile: async (path, data) => { - // Atomic write: sibling temp + rename (store-layout.md §7.1). - await fsMkdir(dirname(path), { recursive: true }); - const tmp = join( - dirname(path), - `.${randomBytes(6).toString("hex")}.tmp`, - ); - try { - await fsWriteFile(tmp, data); - await fsRename(tmp, path); - } catch (err) { - await rm(tmp, { force: true }).catch(() => undefined); - throw err; - } - }, + readFile: (path) => wrap(path, async () => new Uint8Array(await fsReadFile(path))), - appendFile: (path, data) => fsAppendFile(path, data, "utf8"), + writeFile: (path, data) => + wrap(path, async () => { + await fsMkdir(dirname(path), { recursive: true }); + await fsWriteFile(path, data); + }), - exists: async (path) => { - try { - await fsStat(path); - return true; - } catch { - return false; - } - }, + mkdir: (path) => wrap(path, () => fsMkdir(path, { recursive: true }).then(() => undefined)), - stat: async (path) => { - const s = await lstat(path); - return { kind: kindOf(s), size: s.size, mtimeMs: s.mtimeMs }; - }, + rename: (from, to) => + wrap(from, async () => { + await fsMkdir(dirname(to), { recursive: true }); + await fsRename(from, to); + }), - list: (path) => readdir(path), + remove: (path, opts) => + wrap(path, () => rm(path, { recursive: opts?.recursive ?? true, force: true })), - mkdir: async (path) => { - await fsMkdir(path, { recursive: true }); - }, + // lstat + null-on-missing (the store distinguishes absent from error). + stat: (path) => + wrap(path, async () => { + try { + const s = await lstat(path); + return { type: typeOf(s), size: s.size, mtimeMs: s.mtimeMs }; + } catch (err) { + if (codeOf(err) === "not-found") return null; + throw err; + } + }), - remove: (path) => rm(path, { recursive: true, force: true }), + readDir: (path) => + wrap(path, async () => { + const entries = await readdir(path, { withFileTypes: true }); + const out: FsDirent[] = entries.map((e) => ({ + name: e.name, + type: typeOf(e), + })); + return out; + }), - rename: (from, to) => fsRename(from, to), + readlink: (path) => wrap(path, () => fsReadlink(path)), - symlink: async (target, path) => { - // `ln -s` via exec — no fs.symlink on the island. Failure (no `ln`, - // restricted fs) is left for the caller's copy fallback. - const res = await exec.exec("ln", ["-s", target, path]); - if (res.code !== 0) { - throw new Error(`ln -s failed (${res.code}): ${res.stderr.trim()}`); - } - }, + // O_EXCL mutex primitive — returns whether it won the create. + createExclusive: (path, data) => + wrap(path, async () => { + try { + await fsMkdir(dirname(path), { recursive: true }); + await fsWriteFile(path, data, { flag: "wx" }); + return true; + } catch (err) { + if (codeOf(err) === "exists") return false; + throw err; + } + }), - readlink: (path) => fsReadlink(path), + appendFile: (path, data) => wrap(path, () => fsAppendFile(path, data)), - copy: async (from, to) => { - const s = await lstat(from); - if (s.isDirectory()) { - await cp(from, to, { recursive: true }); - } else { - await fsMkdir(dirname(to), { recursive: true }); - await copyFile(from, to); + // `ln -s` via exec — no fs.symlink on the island. Failure (no `ln`, + // restricted fs) is left for the caller's copy fallback. + symlink: async (target, path) => { + const res = await exec.run("ln", ["-s", target, path]); + if (res.code !== 0) { + throw new PortFsError( + "io", + path, + `ln -s failed (${res.code}): ${res.stderr.trim()}`, + ); } }, }); diff --git a/packages/cli/src/root.ts b/packages/cli/src/root.ts index 5224e4a..0e1db15 100644 --- a/packages/cli/src/root.ts +++ b/packages/cli/src/root.ts @@ -1,7 +1,9 @@ /** * Store-root resolution — store-layout.md §3.5. Precedence: - * `--root` flag > `ANYHARNESS_STORE` > `ANYHARNESS_HOME`/harness > - * `~/.agents/harness`. + * `--root` flag > `ANYHARNESS_STORE` > `ANYHARNESS_HOME` > `~/.agents`. + * + * The resolved value is the **agents root** (`~/.agents`) — the sdk owns + * `harness/` underneath it and reads the `skills/` + `mcp.json` siblings. */ export const resolveStoreRoot = ( env: Record, @@ -10,6 +12,5 @@ export const resolveStoreRoot = ( ): string => { if (rootFlag) return rootFlag; if (env.ANYHARNESS_STORE) return env.ANYHARNESS_STORE; - const agentsRoot = env.ANYHARNESS_HOME ?? `${home}/.agents`; - return `${agentsRoot}/harness`; + return env.ANYHARNESS_HOME ?? `${home}/.agents`; }; diff --git a/packages/cli/src/testing/memory-fs.ts b/packages/cli/src/testing/memory-fs.ts index 4240085..57940b1 100644 --- a/packages/cli/src/testing/memory-fs.ts +++ b/packages/cli/src/testing/memory-fs.ts @@ -1,5 +1,18 @@ -/** In-memory FsPort for tests — flat map of path → content. */ -import type { FsPort } from "../api.js"; +/** In-memory FsPort for tests — flat map of path → content (sdk shape). */ +import type { FsDirent, FsPort, FsStat } from "../api.js"; + +class MemFsError extends Error { + readonly code = "not-found"; + readonly path: string; + constructor(path: string) { + super(`not found: ${path}`); + this.name = "FsError"; + this.path = path; + } +} + +const enc = new TextEncoder(); +const dec = new TextDecoder(); export const createMemoryFs = ( seed: Record = {}, @@ -15,51 +28,49 @@ export const createMemoryFs = ( } return out; }; + const prefixOf = (path: string) => (path.endsWith("/") ? path : `${path}/`); const fs: FsPort & { files: Map } = { files, readFile: async (path) => { const v = files.get(path); - if (v === undefined) throw new Error(`not found: ${path}`); - return v; - }, - readFileBytes: async (path) => { - const v = files.get(path); - if (v === undefined) throw new Error(`not found: ${path}`); - return new TextEncoder().encode(v); + if (v === undefined) throw new MemFsError(path); + return enc.encode(v); }, writeFile: async (path, data) => { for (const d of parentDirs(path)) dirs.add(d); - files.set(path, typeof data === "string" ? data : new TextDecoder().decode(data)); + files.set(path, dec.decode(data)); }, appendFile: async (path, data) => { - files.set(path, (files.get(path) ?? "") + data); + files.set(path, (files.get(path) ?? "") + dec.decode(data)); }, - exists: async (path) => files.has(path) || dirs.has(path), - stat: async (path) => { + stat: async (path): Promise => { if (files.has(path)) { - return { kind: "file", size: files.get(path)!.length, mtimeMs: 0 }; + return { type: "file", size: files.get(path)!.length, mtimeMs: 0 }; } - if (dirs.has(path)) return { kind: "directory", size: 0, mtimeMs: 0 }; - throw new Error(`not found: ${path}`); + if (dirs.has(path)) return { type: "directory", size: 0, mtimeMs: 0 }; + return null; }, - list: async (path) => { - const prefix = path.endsWith("/") ? path : `${path}/`; - const names = new Set(); + readDir: async (path): Promise => { + const prefix = prefixOf(path); + const seen = new Map(); for (const key of files.keys()) { if (key.startsWith(prefix)) { - names.add(key.slice(prefix.length).split("/")[0]); + const rest = key.slice(prefix.length); + const name = rest.split("/")[0]; + seen.set(name, rest.includes("/") ? "directory" : "file"); } } - return [...names]; + return [...seen.entries()].map(([name, type]) => ({ name, type })); }, mkdir: async (path) => { dirs.add(path); }, - remove: async (path) => { + remove: async (path, opts) => { files.delete(path); dirs.delete(path); - const prefix = `${path}/`; + if (opts?.recursive === false) return; + const prefix = prefixOf(path); for (const key of [...files.keys()]) { if (key.startsWith(prefix)) files.delete(key); } @@ -69,14 +80,19 @@ export const createMemoryFs = ( }, rename: async (from, to) => { const v = files.get(from); - if (v === undefined) throw new Error(`not found: ${from}`); + if (v === undefined) throw new MemFsError(from); files.delete(from); files.set(to, v); }, - copy: async (from, to) => { - const v = files.get(from); - if (v === undefined) throw new Error(`not found: ${from}`); - files.set(to, v); + readlink: async (path) => { + const v = files.get(path); + if (v === undefined) throw new MemFsError(path); + return v; + }, + createExclusive: async (path, data) => { + if (files.has(path) || dirs.has(path)) return false; + files.set(path, dec.decode(data)); + return true; }, }; return fs; diff --git a/packages/cli/src/testing/mock-store.ts b/packages/cli/src/testing/mock-store.ts index e3979b0..7c88e1b 100644 --- a/packages/cli/src/testing/mock-store.ts +++ b/packages/cli/src/testing/mock-store.ts @@ -1,15 +1,21 @@ /** - * Hand-rolled in-memory Store for tests — implements the pinned Store - * surface (`api.ts`) without touching `@any-harness/sdk` (stub until W8). - * Not part of the shipped cli surface beyond tests; exported so sibling + * Hand-rolled in-memory Store for tests — implements the sdk's Store + * surface without touching node or the filesystem. Exported so sibling * workstreams can reuse it for their own cli-side tests. */ import type { + AuditRecord, DoctorReport, Extension, InstallOptions, + InstallResult, + MaterializeResult, SourceRef, Store, + StoreEntry, + StoreNotification, + StorePaths, + TrustPolicy, VerifyResult, } from "../api.js"; @@ -17,31 +23,89 @@ export interface MockStore extends Store { /** Test inspection: lock-entry-shaped records keyed by extension name. */ entries: Map; /** Test inspection: install calls as received. */ - installs: { source: SourceRef; opts?: InstallOptions }[]; + installs: { source: SourceRef | string; opts?: InstallOptions }[]; + /** Test seeding: audit records `auditLog()` returns. */ + audit: AuditRecord[]; } const idOf = (name: string, version: string): string => `${name}@${version}`; +const pathsOf = (root: string): StorePaths => ({ + root, + harness: `${root}/harness`, + packages: `${root}/harness/packages`, + data: `${root}/harness/data`, + tmp: `${root}/harness/tmp`, + lockfile: `${root}/harness/extensions.lock`, + config: `${root}/harness/config.toml`, + audit: `${root}/harness/audit.log`, + storeJson: `${root}/harness/store.json`, + lock: `${root}/harness/.lock`, + skills: `${root}/skills`, + mcpJson: `${root}/mcp.json`, +}); + export const createMockStore = ( seed: Extension[] = [], + root = "/test/.agents", ): MockStore => { const entries = new Map(); for (const e of seed) entries.set(e.manifest.name, { extension: e, tampered: false }); - const installs: { source: SourceRef; opts?: InstallOptions }[] = []; + const installs: { source: SourceRef | string; opts?: InstallOptions }[] = []; + const audit: AuditRecord[] = []; + const listeners = new Set<(n: StoreNotification) => void>(); const find = (name: string) => { - const entry = entries.get(name); + const entry = entries.get(name) ?? entries.get(name.split("@")[0]); if (!entry) throw new Error(`extension not found: ${name}`); return entry; }; - return { + const store: MockStore = { entries, installs, - list: async () => [...entries.values()].map((e) => e.extension), - install: async (source, opts) => { - installs.push({ source, ...(opts ? { opts } : {}) }); - const name = source.path?.split("/").pop() ?? source.uri.split("/").pop() ?? source.uri; + audit, + root, + paths: pathsOf(root), + ports: {} as Store["ports"], + + list: async (opts) => { + let out = [...entries.values()].map((e) => e.extension); + if (opts?.enabledOnly) out = out.filter((e) => e.enabled); + if (opts?.kinds?.length) { + out = out.filter( + (e) => + opts.kinds!.includes(e.kind) || + e.provides?.some((p) => opts.kinds!.includes(p)) === true, + ); + } + return out; + }, + + get: async (nameOrId): Promise => { + const entry = find(nameOrId); + return { + name: entry.extension.manifest.name, + extension: entry.extension, + entry: { + kind: entry.extension.kind, + manifest: entry.extension.manifest, + source: { type: "local", uri: "/mock" }, + integrity: "sha256-mock", + installedAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + targets: [], + }, + packageDir: `${root}/harness/packages/${entry.extension.manifest.name}`, + }; + }, + + install: async (source, opts): Promise => { + const src: SourceRef = + typeof source === "string" ? { type: "git", uri: source } : source; + installs.push({ source: src, ...(opts ? { opts } : {}) }); + const name = + src.path?.split("/").pop() ?? src.uri.split("/").pop() ?? src.uri; const extension: Extension = { id: idOf(name, "1.0.0"), kind: "plugin", @@ -49,27 +113,89 @@ export const createMockStore = ( enabled: true, }; entries.set(name, { extension, tampered: false }); - return extension; + return { + extension, + entry: { + kind: extension.kind, + manifest: extension.manifest, + source: src, + integrity: "sha256-mock", + installedAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + targets: [], + }, + location: `${root}/harness/packages/${name}`, + warnings: [], + }; }, + remove: async (name) => { - find(name); - entries.delete(name); + const entry = find(name); + entries.delete(entry.extension.manifest.name); + return entry.extension; }, + setEnabled: async (name, enabled) => { const entry = find(name); entry.extension = { ...entry.extension, enabled }; return entry.extension; }, - materialize: async (name) => { + + materialize: async (name): Promise => { find(name); - return { materializedTo: [`~/.agents/skills/${name}`] }; + return { + skills: [ + { + name, + manifest: { name, version: "1.0.0" }, + files: [], + materializedTo: `${root}/skills/${name}`, + }, + ], + }; }, + verify: async (name): Promise => { const entry = find(name); return entry.tampered - ? { ok: false, expected: "sha256-aaa", actual: "sha256-bbb" } - : { ok: true, expected: "sha256-aaa", actual: "sha256-aaa" }; + ? { + ok: false, + expected: "sha256-aaa", + actual: "sha256-bbb", + extension: entry.extension.manifest, + } + : { + ok: true, + expected: "sha256-aaa", + actual: "sha256-aaa", + extension: entry.extension.manifest, + }; }, - doctor: async (): Promise => ({ ok: true, findings: [] }), + + doctor: async (): Promise => ({ findings: [] }), + + policy: async (): Promise => ({ + exec: { + setup: "ask", + hooks: "ask", + mcp: "ask", + skillScripts: "ask", + nonInteractive: "deny", + }, + sources: { allow: ["*"], deny: [] }, + }), + + auditLog: async () => [...audit], + + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + + readLock: async () => ({ + version: 1, + extensions: {}, + }), }; + return store; }; diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index ffb080e..fec1fd2 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -5,8 +5,9 @@ export default defineConfig({ format: ["esm"], // ".js" output (package is type:module) — ci.yml smoke-tests dist/cli.js. outExtensions: () => ({ js: ".js", dts: ".d.ts" }), - // Workspace dep stays external — resolved through pnpm at runtime. - deps: { neverBundle: ["@any-harness/sdk"] }, + // Workspace dep is bundled in — dist/cli.js must run standalone + // (scriptc packaging ships a single self-contained artifact). + deps: { alwaysBundle: ["@any-harness/sdk"] }, sourcemap: true, clean: true, minify: false, diff --git a/packages/sdk/src/ports.ts b/packages/sdk/src/ports.ts index c12bbe9..51848f0 100644 --- a/packages/sdk/src/ports.ts +++ b/packages/sdk/src/ports.ts @@ -34,30 +34,12 @@ export class FsError extends Error { /** * 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. + * (TextEncoder/TextDecoder/URL/setTimeout/crypto/btoa) — present in node + * ≥11, every browser, and every worker runtime. They come from the + * package tsconfig's `lib: ["DOM"]` (self-check) or the consumer's + * @types/node — never declared here, where they would collide with + * either. */ -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, diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index c2104f6..fac6008 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "lib": ["ES2022", "DOM", "DOM.Iterable"] }, "include": ["src/**/*"] }