From 4c52e5c314c98066af248b343f6e65f2727c8235 Mon Sep 17 00:00:00 2001 From: Tom Main Date: Tue, 13 Oct 2026 17:59:05 +0000 Subject: [PATCH 1/2] feat: unify tool execution and add durable bounded work --- .github/workflows/ci.yml | 67 +++ .gitignore | 1 + CLAUDE.md | 23 +- README.md | 89 +++- docs/AGENT-RUNTIMES.md | 241 ++++++--- docs/ARCHITECTURE.md | 101 +++- docs/RUNS.md | 49 ++ docs/SIFTABLE.md | 62 ++- docs/TYPESCRIPT.md | 24 +- package-lock.json | 210 +++++++- package.json | 178 ++++++- plugins/hermes-openfunctions/mcp.json | 11 + plugins/hermes-openfunctions/plugin.json | 13 + .../skills/durable-work/SKILL.md | 28 + plugins/openclaw-execufunction/index.ts | 77 ++- .../openclaw.plugin.json | 204 +++---- .../openclaw-execufunction/package-lock.json | 210 +++++++- plugins/openclaw-execufunction/package.json | 17 +- .../scripts/capture-contracts.mjs | 37 ++ .../scripts/sync-contract.mjs | 19 +- .../scripts/verify-contract.mjs | 34 +- plugins/openclaw-execufunction/src/client.ts | 14 +- .../openclaw-execufunction/src/work-tools.ts | 25 + .../tool-contracts.json | 498 ++++++++++++++++++ plugins/openclaw-openfunctions/README.md | 13 +- .../openclaw-openfunctions/package-lock.json | 48 +- plugins/openclaw-openfunctions/package.json | 15 +- .../scripts/check-contracts.mjs | 22 + plugins/openclaw-openfunctions/src/index.ts | 11 +- .../openclaw-openfunctions/src/registry.ts | 4 +- .../tool-contracts.json | 13 + plugins/pi-openfunctions/README.md | 8 +- plugins/pi-openfunctions/package-lock.json | 57 +- plugins/pi-openfunctions/package.json | 9 +- plugins/pi-openfunctions/src/index.ts | 28 +- plugins/pi-openfunctions/src/registry.ts | 4 +- scripts/prepare-package.mjs | 10 + src/cli.ts | 164 ++++++ src/framework/adapters/chat.ts | 13 +- src/framework/adapters/index.ts | 2 + src/framework/adapters/types.ts | 15 + src/framework/agents.ts | 79 +-- src/framework/artifacts.ts | 79 +++ src/framework/chat-agent-http.ts | 22 +- src/framework/chat-agent-resolve.ts | 33 +- src/framework/chat-agent-types.ts | 25 +- src/framework/chat-agent.ts | 219 ++++++-- src/framework/context.ts | 187 ++++++- src/framework/core.ts | 11 + src/framework/execution-journal.ts | 87 +++ src/framework/execution-types.ts | 106 ++++ src/framework/executor.ts | 348 ++++++++++++ src/framework/hermes.ts | 32 ++ src/framework/index.ts | 33 +- src/framework/journal-file.ts | 404 ++++++++++++++ src/framework/openclaw.ts | 58 +- src/framework/pg-store.ts | 246 +++++---- src/framework/pi.ts | 29 +- src/framework/rag.ts | 7 +- src/framework/registry.ts | 144 ++--- src/framework/runs.ts | 7 + src/framework/runtime-tool-bridge.ts | 65 ++- src/framework/server.ts | 194 ++++--- src/framework/session.ts | 138 ++++- src/framework/store.ts | 28 +- src/framework/test-runner.ts | 2 +- src/framework/tool-scheduler.ts | 170 ++++++ src/framework/types.ts | 92 +++- src/framework/validate.ts | 381 +++++++------- src/framework/work/controller.ts | 473 +++++++++++++++++ src/framework/work/grants.ts | 193 +++++++ src/framework/work/index.ts | 8 + src/framework/work/journal.ts | 45 ++ src/framework/work/local-backend.ts | 99 ++++ src/framework/work/local-fixture.ts | 42 ++ src/framework/work/tools.ts | 22 + src/framework/work/types.ts | 44 ++ src/framework/work/verification.ts | 70 +++ src/framework/workflows.ts | 21 +- src/index.ts | 19 +- src/providers/execufunction/client.ts | 16 +- src/providers/execufunction/index.ts | 1 - src/providers/execufunction/sdk-tools.ts | 131 ++++- src/providers/execufunction/tools.ts | 84 +-- src/providers/mcp/index.ts | 309 +++++++++++ src/providers/siftable-work/index.ts | 277 ++++++++++ src/runtime/work-profile.ts | 106 ++++ src/workshop.ts | 17 + test-client/fixtures/hermes-host-proof.py | 386 ++++++++++++++ .../siftable-work-recovery-1.5.1.json | 392 ++++++++++++++ test-client/fixtures/work-crash-fixture.ts | 28 + test-client/provision-hermes-runtime.mjs | 25 + test-client/run-caller-convergence-tests.ts | 110 ++++ test-client/run-chat-execution-tests.ts | 173 ++++++ test-client/run-context-tests.ts | 153 ++++++ test-client/run-durability-tests.ts | 121 +++++ test-client/run-execution-tests.ts | 286 ++++++++++ test-client/run-framework-tests.ts | 47 +- test-client/run-grant-tests.ts | 77 +++ test-client/run-hermes-config-tests.ts | 26 + test-client/run-hermes-tests.mjs | 81 +++ test-client/run-host-plugin-tests.mjs | 99 ++++ test-client/run-legible-execution-tests.ts | 45 +- test-client/run-mcp-provider-tests.ts | 218 ++++++++ test-client/run-mcp-tests.ts | 252 +++++++++ test-client/run-package-tests.mjs | 72 +++ test-client/run-pg-store-tests.ts | 88 ++++ test-client/run-runtime-bridge-tests.ts | 107 ++++ test-client/run-session-v2-tests.ts | 134 +++++ test-client/run-siftable-provider-tests.ts | 35 ++ .../run-siftable-work-provider-tests.ts | 191 +++++++ test-client/run-tool-scheduler-tests.ts | 116 ++++ test-client/run-validation-tests.ts | 172 ++++++ test-client/run-work-cli-tests.ts | 67 +++ test-client/run-work-tests.ts | 339 ++++++++++++ tsconfig.build.json | 17 + 116 files changed, 10501 insertions(+), 1227 deletions(-) create mode 100644 plugins/hermes-openfunctions/mcp.json create mode 100644 plugins/hermes-openfunctions/plugin.json create mode 100644 plugins/hermes-openfunctions/skills/durable-work/SKILL.md create mode 100644 plugins/openclaw-execufunction/scripts/capture-contracts.mjs create mode 100644 plugins/openclaw-execufunction/src/work-tools.ts create mode 100644 plugins/openclaw-execufunction/tool-contracts.json create mode 100644 plugins/openclaw-openfunctions/scripts/check-contracts.mjs create mode 100644 plugins/openclaw-openfunctions/tool-contracts.json create mode 100644 scripts/prepare-package.mjs create mode 100644 src/cli.ts create mode 100644 src/framework/artifacts.ts create mode 100644 src/framework/core.ts create mode 100644 src/framework/execution-journal.ts create mode 100644 src/framework/execution-types.ts create mode 100644 src/framework/executor.ts create mode 100644 src/framework/journal-file.ts create mode 100644 src/framework/tool-scheduler.ts create mode 100644 src/framework/work/controller.ts create mode 100644 src/framework/work/grants.ts create mode 100644 src/framework/work/index.ts create mode 100644 src/framework/work/journal.ts create mode 100644 src/framework/work/local-backend.ts create mode 100644 src/framework/work/local-fixture.ts create mode 100644 src/framework/work/tools.ts create mode 100644 src/framework/work/types.ts create mode 100644 src/framework/work/verification.ts create mode 100644 src/providers/mcp/index.ts create mode 100644 src/providers/siftable-work/index.ts create mode 100644 src/runtime/work-profile.ts create mode 100644 src/workshop.ts create mode 100644 test-client/fixtures/hermes-host-proof.py create mode 100644 test-client/fixtures/siftable-work-recovery-1.5.1.json create mode 100644 test-client/fixtures/work-crash-fixture.ts create mode 100644 test-client/provision-hermes-runtime.mjs create mode 100644 test-client/run-caller-convergence-tests.ts create mode 100644 test-client/run-chat-execution-tests.ts create mode 100644 test-client/run-context-tests.ts create mode 100644 test-client/run-durability-tests.ts create mode 100644 test-client/run-execution-tests.ts create mode 100644 test-client/run-grant-tests.ts create mode 100644 test-client/run-hermes-config-tests.ts create mode 100644 test-client/run-hermes-tests.mjs create mode 100644 test-client/run-host-plugin-tests.mjs create mode 100644 test-client/run-mcp-provider-tests.ts create mode 100644 test-client/run-mcp-tests.ts create mode 100644 test-client/run-package-tests.mjs create mode 100644 test-client/run-pg-store-tests.ts create mode 100644 test-client/run-runtime-bridge-tests.ts create mode 100644 test-client/run-session-v2-tests.ts create mode 100644 test-client/run-siftable-work-provider-tests.ts create mode 100644 test-client/run-tool-scheduler-tests.ts create mode 100644 test-client/run-validation-tests.ts create mode 100644 test-client/run-work-cli-tests.ts create mode 100644 test-client/run-work-tests.ts create mode 100644 tsconfig.build.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 614cd8b..5b9da4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,70 @@ jobs: - name: Test run: npm test + + - name: Execution and recovery regressions + run: npm run test:focused + + - name: Compiler parity + run: npm run verify:typescript + + - name: Packed consumer and compiled grant CLI + run: npm run test:package && npm run test:work-cli + + native-hosts: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run build + - name: Install pinned native plugin SDKs + run: | + npm ci --ignore-scripts --prefix plugins/openclaw-execufunction + npm ci --ignore-scripts --prefix plugins/openclaw-openfunctions + npm ci --ignore-scripts --prefix plugins/pi-openfunctions + - name: Real SDK types manifests loaders and bounded work + run: npm run test:hosts + + hermes: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: NousResearch/hermes-agent + ref: 7cc3fb8cb55898747dd1dfa56fda3e16ebba028e + path: .host-fixtures/hermes-agent + - uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: npm + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: npm ci + - name: Provision the official managed Hermes interpreter + run: | + node test-client/provision-hermes-runtime.mjs --hermes-root "$GITHUB_WORKSPACE/.host-fixtures/hermes-agent" --runtime-dir "$RUNNER_TEMP/hermes-runtime" --data-dir "$RUNNER_TEMP/hermes-provision-home" --out "$RUNNER_TEMP/hermes-runtime.json" + - name: Real portable host grants execution and crash recovery + run: | + HERMES_TEST_PYTHON=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).python)' "$RUNNER_TEMP/hermes-runtime.json") + npm run test:hermes -- --hermes-root "$GITHUB_WORKSPACE/.host-fixtures/hermes-agent" --python "$HERMES_TEST_PYTHON" --runtime-dir "$RUNNER_TEMP/hermes-runtime" --mode work --install-mode online --out "$RUNNER_TEMP/hermes-evidence" + - name: Retain host evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: hermes-node-${{ matrix.node }} + path: | + ${{ runner.temp }}/hermes-runtime.json + ${{ runner.temp }}/hermes-evidence + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 27fd7e8..8d2c1b7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist/ !.env.example .DS_Store .data/ +.host-fixtures/ # OpenMemory - IDE/Assistant specific rules .windsurf/rules/openmemory.md diff --git a/CLAUDE.md b/CLAUDE.md index abd8f79..5787ecf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,10 @@ src/ context.ts # Context provider interface — connectProvider(), contextPrompt() server.ts # startServer() — MCP server wrapper types.ts # TypeScript interfaces - index.ts # Re-exports everything + index.ts # Side-effect-free public framework exports + executor.ts # Canonical validation, policy, scheduling and receipts + execution-journal.ts # Standalone durable action evidence + work/ # Opt-in bounded attempts, grants, leases and verification providers/ # Context provider implementations execufunction/ # ExecuFunction reference provider (tasks, calendar, CRM, etc.) examples/ # Read these for patterns @@ -37,7 +40,9 @@ src/ utilities/ # Stateless helpers — no store needed (beginner) my-tools/ # Student builds here index.ts # Add tools to the myTools array - index.ts # Entry point — registers all tools, starts MCP server + index.ts # Side-effect-free package library entry + workshop.ts # Source workshop — registers tools and starts MCP + cli.ts # Compiled npm executable; serve/config/work commands test-client/ cli.ts # Interactive CLI for testing tools (npm run test-tools) run-tests.ts # Tool-test runner — runs `tests` arrays on tool defs @@ -110,7 +115,7 @@ examples for stateless tools. 1. Define tools in `src/my-tools/index.ts` 2. Add them to the `myTools` array export -3. They are automatically registered by `src/index.ts` +3. They are automatically registered by `src/workshop.ts` through `register-tools.ts` 4. Test with `npm run test-tools` or `npm run dev` ## Framework Exports @@ -158,6 +163,9 @@ npm run dev # Auto-restarts on save (tsx watch) npm run inspect # MCP Inspector web UI npm run gemini # Chat with Gemini using your tools (needs GEMINI_API_KEY) npm start # Start MCP server for Claude Desktop +npm run typecheck # Check development/test types +npm run test:focused # Local execution, journal, policy and recovery regressions +npm run test:package # Fresh packed consumer and declarations ``` ## Ralph Loops @@ -201,9 +209,12 @@ agent's output. Returns `CrewResult` with a `ralph` summary attached. ### Siftable provider (`src/providers/execufunction/`) -Wraps `@siftable/mcp-server@^1.2.9`. Exports `createSiftableProvider()` -(and a deprecated `createExecuFunctionProvider` alias). 31 tools across -10 domains today; ~80 more SDK methods reachable via `client.raw()`. +Uses optional peer `@siftable/mcp-server@^1.5.1` with exact dev SDK 1.5.1. +Exports `createSiftableProvider()` and deprecated `createExecuFunctionProvider`. +SDK feature gates and transport policy determine the catalog; unsupported +hosted work sessions are excluded from embedded discovery. Hosted MCP is +separate; its durable controller remains blocked by documented public contract +gaps. Never invent principals, receipt lookup, or retry guarantees. Auth resolution: explicit option → `SIFT_PAT` env → `EXF_PAT` env. Same chain for `SIFT_API_URL` and `SIFT_WORKSPACE_ID`. diff --git a/README.md b/README.md index 8436037..6e8e9df 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ openFunctions is an MIT-licensed TypeScript framework for building AI-callable tools and exposing them through [MCP](https://modelcontextprotocol.io), chat adapters, workflows, and agents. Its core runtime is simple: -`ToolDefinition -> ToolRegistry -> AIAdapter` +`ToolDefinition -> ToolRegistry -> ToolExecutor -> adapter or host` Everything else composes on top of that: @@ -56,6 +56,66 @@ npm run test-tools The first thing to build is a tool, not an agent. +### Use the compiled library + +The package root is a library. Importing it does not load `.env`, register +workshop tools, create stores, or start a server. Build and pack this checkout +to try the revised package before publication: + +```sh +npm run build +npm pack +# In a separate consumer: +npm install /absolute/path/to/openfunction-1.0.0.tgz +``` + +```ts +import { defineTool, ToolRegistry, ok } from "openfunction/framework/core"; + +const registry = new ToolRegistry(); +registry.register(defineTool({ + name: "hello_world", + description: "Return a greeting for the supplied name", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + handler: async ({ name }) => ok({ greeting: `Hello ${name}` }), +})); +export { registry }; +``` + +Serve an exported registry with `openfunction serve --module +/absolute/path/to/tools.js`. The existing `npm start`, `npm run dev`, and +workshop commands still use the source workshop entry. Siftable and PostgreSQL +are optional peers; root, core, and framework imports work without either. + +### Complete bounded work through Hermes + +The opt-in work profile selects a finite trusted step catalog, caller-owned +state and grants, an explicit local work item, and exact verification bindings. +The compiled CLI reviews and issues authority separately from Hermes's tool +approval gate: + +```sh +openfunction work prepare --config /absolute/profile.json --work-item job --out /absolute/preview.json +openfunction work authorize --config /absolute/profile.json --preview /absolute/preview.json +openfunction hermes-config --config /absolute/profile.json +openfunction serve --profile work --config /absolute/profile.json +``` + +Authorization displays the bound scope and limits. Type `AUTHORIZE` to permit +execution; automatic completion stays off unless you separately type +`COMPLETE`. Changes to the configuration, contract, or plan invalidate the +preview. Grant revocation takes effect at the next admission and completion. +See [runtime integrations](docs/AGENT-RUNTIMES.md) for profile configuration, +portable installation, and the verified host commands. + +The controller records effects and independent verification, renews a bounded +lease during long steps, and refuses completion with stale proof or unknown +effects. It covers actions performed through its executor. Native host actions +remain outside that journal. Local grant files assume trusted same-user code; +they provide no sandbox against a process that can modify their custody. +Hosted Siftable calls use authenticated MCP, but its durable-work backend is +disabled until the missing public recovery and parser contracts are available. + ## The Mental Model A tool is your business logic plus a schema the AI can read: @@ -344,10 +404,11 @@ The provider projects its default tool set directly from the published [`@siftable/mcp-server`](https://www.npmjs.com/package/@siftable/mcp-server) SDK. That keeps tool names, schemas, feature gates, transport containment, work-item lifecycle rules, structured receipts, and execution behavior aligned -with the installed Siftable MCP version. The current `1.2.27` package declares -136 tools; 114 are callable with its default feature flags. Optional dataset -and ontology tools appear automatically when their Siftable feature flags are -enabled. Set `includeLegacyAliases: true` only when migrating callers that still +with the installed Siftable MCP version. The development SDK is pinned to +`1.5.1`; drift checks compare its actual enabled catalog and schemas. Hosted +session tools require custody unavailable to this embedded provider and are +excluded. Optional dataset and ontology tools follow their Siftable feature +flags. Set `includeLegacyAliases: true` only when migrating callers that still invoke the old hand-written `exf_*` names. ```ts @@ -399,10 +460,12 @@ adds an OpenClaw runtime dependency to the framework. Two reference plugins live in `plugins/`: -- **`openclaw-execufunction/`** — Siftable for OpenClaw. It derives all - currently enabled tools from `@siftable/mcp-server`, delegates execution to - the SDK, and generates its static `contracts.tools` manifest from the same - source. Plugin id `execufunction` remains for config compatibility. +- **`openclaw-execufunction/`** — Mixed Siftable and opt-in bounded-work plugin. + It projects the supported embedded SDK catalog through the shared executor, + resolves credentials only on invocation, and snapshots exact tool contracts. + Plugin id `execufunction` remains for config compatibility. Its legacy mixed + entry uses the SDK registration path; the static validator targets the + separate tool-only plugin below. - **`openclaw-openfunctions/`** — Current tool-only reference using `defineToolPlugin()`, compiled ESM, and generated manifest metadata. Marked `private:true`; it imports the colocated framework by relative path. @@ -432,11 +495,15 @@ openFunctions/ │ │ └── execufunction/ # Siftable context provider (wraps @siftable/mcp-server) │ ├── examples/ # Reference tool patterns │ ├── my-tools/ # Your tools -│ └── index.ts # MCP entrypoint +│ ├── runtime/ # Trusted bounded-work profile loader +│ ├── cli.ts # Compiled serve and grant commands +│ ├── workshop.ts # Source workshop MCP entrypoint +│ └── index.ts # Side-effect-free library entrypoint ├── plugins/ │ ├── openclaw-execufunction/ # Siftable plugin for openclaw (publishable) │ ├── openclaw-openfunctions/ # Reference: current tool-plugin bridge (private) -│ └── pi-openfunctions/ # Reference: native Pi extension bridge (private) +│ ├── pi-openfunctions/ # Reference: native Pi extension bridge (private) +│ └── hermes-openfunctions/ # Portable MCP plugin and bounded-work skill ├── docs/ # Architecture docs ├── scripts/ # chat, create-tool, docs ├── test-client/ # CLI tester + test runner diff --git a/docs/AGENT-RUNTIMES.md b/docs/AGENT-RUNTIMES.md index 03bd5ab..f22ffc7 100644 --- a/docs/AGENT-RUNTIMES.md +++ b/docs/AGENT-RUNTIMES.md @@ -1,119 +1,186 @@ # Agent runtime integrations -OpenFunction definitions can be exposed through MCP or adapted to a host's -native extension API. The framework keeps all three integrations dependency -free: install a host SDK only in the plugin or extension package that needs it. +Hermes consumes OpenFunction through MCP. OpenClaw and Pi use thin native +projections of the same executor. Native SDKs belong to their plugin packages, +so importing the framework does not load a host runtime. -## Hermes Agent +## Hermes configuration and portable installation -Hermes consumes OpenFunction through MCP, its supported boundary for external -tool servers. `createHermesMcpConfig()` builds the `mcp_servers` fragment for -`~/.hermes/config.yaml` and snapshots an explicit tool allowlist from the -registry. +Use a separately installed, version-tested OpenFunction runtime. Portable +plugins do not install npm dependencies or fetch an unpinned runtime on demand. +`plugins/hermes-openfunctions/` contains the manifest, MCP server definition, +and namespaced durable-work skill. Its command is the bare `openfunction` +executable on PATH, as required by the pinned portable schema. Caller-owned +configuration is selected through `${PLUGIN_DATA}`. + +The tested portable schemas are Agent Plugins v1. Hermes trust belongs in +`plugin.json` under `extensions["com.nousresearch.hermes"].servers.openfunctions`. +The plugin starts disabled; installation performs no claiming or execution. +Enable it explicitly in the selected profile after configuring the runtime and +grants. Instructions/toolsets stay stable during a conversation; refresh data +through tool results or a new session boundary. + +For ordinary MCP configuration, generate a merge-ready fragment: ```ts -import { - createHermesMcpConfig, - registry, -} from "../src/framework/index.js"; +import { createHermesMcpConfig } from "openfunction/framework/hermes"; +import { registry } from "./tools.js"; const config = createHermesMcpConfig(registry, { - command: "node", - args: ["/absolute/path/to/openfunction/dist/src/index.js"], + command: "openfunction", + args: ["serve", "--module", "/absolute/tools.js"], + cwd: "/absolute/caller-state", + protocol: "legacy", + trust: "untrusted", timeout: 120, connectTimeout: 30, - supportsParallelToolCalls: true, - filter: (tool) => tool.tags?.includes("hermes") ?? false, + supportsParallelToolCalls: false, resources: false, prompts: false, }); ``` -Serialize or merge the returned object into YAML, then start a new Hermes -session or run `/reload-mcp`. Prefer an absolute server entrypoint because -Hermes may start the subprocess from a different working directory. +Merge it into the selected profile's configuration. The helper does not edit +Hermes files or print credentials. `tools.include` captures the selected catalog; +regenerate and review it when exposing new tools. -The generated `tools.include` is intentional. Adding a new registry tool later -does not silently grant it to Hermes; regenerate and review the config. +## Local durable work -## Pi (`@earendil-works/pi`) +The work profile reads a protected absolute configuration path. Public tools +prepare, start, step, verify, inspect, reconcile, and finish one explicitly +selected item. It does not claim the next job or expose an unrestricted shell. +`of_work_finish` may request review, block, or release explicitly; automatic +completion still requires the matching completion grant. -Pi has a native TypeScript extension API. `registerPiTools()` accepts the -small part of `ExtensionAPI` that OpenFunction needs and registers compatible -tool definitions without importing Pi in the framework itself. +This example configures a job in an explicitly selected local workspace: ```ts -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { - registerPiTools, - type PiExtensionApiLike, -} from "openfunction/framework"; -import { registry } from "./registry.js"; - -export default function (pi: ExtensionAPI) { - registerPiTools(pi as unknown as PiExtensionApiLike, registry, { - namePrefix: "of_", - promptSnippet: (tool) => `Use ${tool.name} from OpenFunction`, - }); -} +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createLocalFixtureBindings } from "openfunction/framework/work"; + +const workspaceRoot = "/absolute/local-job"; +mkdirSync(workspaceRoot, { recursive: true, mode: 0o700 }); +writeFileSync(join(workspaceRoot, "fixture.json"), + '{"status":"needs_correction","value":0}\n', { mode: 0o600 }); +writeFileSync(join(workspaceRoot, "verify.mjs"), + 'import fs from "node:fs";const x=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));if(x.status!=="corrected"||x.value!==1)process.exit(1);console.log("verified");\n', + { mode: 0o600 }); +const binding = createLocalFixtureBindings({ + workspaceRoot, fixturePath: "fixture.json", verifierPath: "verify.mjs", +}); +writeFileSync(join(workspaceRoot, "profile.json"), JSON.stringify({ + schemaVersion: 1, + workspaceRoot, + dataDir: join(workspaceRoot, "state"), + grantDir: join(workspaceRoot, "grants"), + backend: { + kind: "local", backendId: "local-example", workspaceId: "example", + workItems: [{ + workItemId: "job", contract: binding.contract, + plan: { id: "fixture-plan", version: 1, state: "active", steps: [binding.verificationStep] }, + }], + }, + stepCatalog: { kind: "fixture", fixturePath: "fixture.json", verifierPath: "verify.mjs" }, +}), { mode: 0o600 }); ``` -The JSON Schema subset used by `defineTool()` passes through as Pi's -`parameters` schema. Successful calls return text content plus the original -`ToolResult` in `details`. Failed validation or handlers throw because that is -how current Pi sets `isError: true` on a tool result. +Use the same installed Node runtime to generate the plan and launch the CLI; +verification binds the executable, arguments, environment, and source inputs. +Review and issue authority through the shipped CLI: -See `plugins/pi-openfunctions/` for a loadable local reference extension. +```sh +openfunction work prepare --config /absolute/local-job/profile.json --work-item job --out /absolute/local-job/preview.json +openfunction work authorize --config /absolute/local-job/profile.json --preview /absolute/local-job/preview.json +openfunction hermes-config --config /absolute/local-job/profile.json +``` -## OpenClaw +The review displays exact roots, paths, actions, step/specification bindings, +verification, expiry, and limits. `AUTHORIZE` grants execution. A separate +`COMPLETE` decision enables automatic completion; otherwise it remains off. +References in model arguments cannot issue grants. Revocation remains available +when the target fixture or trusted step module disappears: -Current OpenClaw has two relevant plugin paths: +```sh +openfunction work grants list --config /absolute/local-job/profile.json +openfunction work grants revoke --config /absolute/local-job/profile.json --grant +``` -- `defineToolPlugin()` for a static, tool-only plugin. It exposes metadata that - `openclaw plugins build` writes to `openclaw.plugin.json` before runtime code - is loaded. Use `toOpenclawToolPluginTools()` for this path. -- `definePluginEntry()` plus `api.registerTool()` for mixed-capability plugins - or plugins whose tool names are computed dynamically. Existing - `toOpenclawTools()` and `toolToOpenclaw()` remain for this path. +For a practical job, configure `stepCatalog: { kind: "module", modulePath: +"/absolute/trusted-bindings.js" }`. That trusted module exports +`createWorkBindings({ workspaceRoot })`, returning tools, finite step bindings, +and verification bindings. Reviewed configuration selects paths and commands; +the model selects registered keys and parameters. + +Grant/state custody uses protected permissions (0600 files and 0700 directories). +A same-user process with write access can change it; stronger separation requires +host permissions and OS isolation. Hermes approval precedes an MCP write request +but provides no signed server-side grant. Hosted/service use requires a genuine +authenticated issuer and policy binding. + +The controller renews the lease independently of a completed MCP request. +Cancellation stops admissions and reaches cooperative handlers; started effects +retain receipts. Restart reads saved evidence and requires explicit reconciliation +before resuming. Unknown writes do not replay. The fixture catalog provides an +exact-digest readback for explicit reconciliation; a separate linked receipt +records that proof while the original uncertain execution stays unchanged. +Without a supported affirmative oracle, the attempt remains unresolved. +Evidence binds current inputs and +mutation epoch; later corrections invalidate it. Native terminal/browser/file +actions outside OpenFunction are not journaled. + +## Pi + +`registerPiTools()` and `toolToPi()` preserve native call IDs, signals, results, +and execution receipts. Text/image content survives directly; absent resource +features produce explicit text notices. Unknown failures throw a +verify-before-retry warning with the canonical receipt attached. + +The reference extension uses the real SDK and `Type.Unsafe()` for a schema +already checked by the executor. It imports documented package subpaths rather +than source-relative internals. Pi contains no separate durable runtime. -```ts -import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin"; -import type { TSchema } from "typebox"; -import { toOpenclawToolPluginTools } from "openfunction/framework"; -import { registry } from "./registry.js"; - -const definitions = toOpenclawToolPluginTools(registry); - -export default defineToolPlugin({ - id: "my-openfunctions-tools", - name: "My OpenFunction tools", - description: "Expose an OpenFunction registry to OpenClaw.", - tools: (tool) => definitions.map((definition) => tool({ - ...definition, - parameters: definition.parameters as TSchema, - })), -}); -``` +## OpenClaw -For distributable plugins, ship compiled ESM, keep `typebox` in runtime -dependencies, declare `openclaw >=2026.5.17` as a peer, and run both: +The static `defineToolPlugin()` path uses `toOpenclawToolPluginTools()`. Its +metadata is checked with real `openclaw plugins build` and `plugins validate`, +including exact tool names and schema digests. -```bash -openclaw plugins build --entry ./dist/index.js --check -openclaw plugins validate --entry ./dist/index.js -``` +The mixed `definePluginEntry()` path uses `toOpenclawTools()`. The Siftable plugin +retains its `execufunction` identity and routes SDK tools through a registry and +executor. Credentials remain lazy. Local work tools are registered independently +and use trusted plugin `workConfigPath` (or `OPENFUNCTION_WORK_CONFIG`). Discovery +performs no login, claim, or network work. -`plugins/openclaw-openfunctions/` demonstrates the generated tool-only path. -`plugins/openclaw-execufunction/` uses direct registration because its surface -is projected dynamically from the installed Siftable MCP SDK. Its checked-in -`contracts.tools` list is generated from that same SDK and verified against -runtime registrations, so OpenClaw discovery cannot drift from execution. +Mixed failures retain error blocks and `isError`; static tool-plugin failures +throw. Both retain unknown certainty and receipts. The static CLI validator +requires `defineToolPlugin` metadata and cannot validate the legacy mixed entry; +that path is checked through real SDK types, registration/calls, and exact +manifest parity instead. -## Contract comparison +## Reproducible host checks + +Hermes is pinned to `7cc3fb8cb55898747dd1dfa56fda3e16ebba028e`. Native checks +use OpenClaw `2026.7.1-2` and Pi `0.84.1`, pinned in plugin lockfiles. These are +tested versions, not promises about future releases. + +```sh +node test-client/provision-hermes-runtime.mjs --hermes-root /absolute/hermes-agent --runtime-dir /absolute/managed-runtime --data-dir /absolute/provisioning-home --out /absolute/runtime.json +node test-client/run-hermes-tests.mjs --hermes-root /absolute/hermes-agent --python /absolute/managed-python --runtime-dir /absolute/managed-runtime --mode work +npm run test:hosts +``` -| Runtime | Boundary | Failure signal | Discovery safeguard | -|---|---|---|---| -| Hermes | MCP stdio | MCP error result | Explicit `tools.include` snapshot | -| Pi | `ExtensionAPI.registerTool()` | Throw from `execute()` | Extension-selected registry filter | -| OpenClaw tool plugin | `defineToolPlugin()` | Throw from `execute()` | Generated `contracts.tools` manifest | -| OpenClaw mixed plugin | `api.registerTool()` | Throw from `execute()` | Generated and runtime-verified manifest contract | +Provisioning uses Hermes's official package manager and isolated paths. The +driver exercises ordinary MCP, a pinned local Git portable install while disabled, +explicit enablement, skill/tool discovery, approval/refusal, and profile isolation. +Its deterministic model endpoint is local and spends no model credits. + +The work lane uses the installed tarball and real grant CLI. It checks renewal +during a step longer than the lease, lost model delivery after a durable result, +actual process death/restart, exact result recovery without repeat effects, +fresh verification/completion, revocation, stale-subject refusal, and crashes +between effect and receipt with both supported and unsupported readback. Logs +record exact package/host/runtime versions and +the tested loss boundary. Missing setup is an explicit failure or unproven boundary, +never a successful silent skip. Cold CI may use `--install-mode online` to acquire +tarball dependencies; tool/model traffic remains local. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 16d5f23..bf7d820 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ openFunctions is easiest to understand if you start from the runtime it is built on: -`ToolDefinition -> ToolRegistry -> AIAdapter` +`ToolDefinition -> ToolRegistry -> ToolExecutor -> adapter or host` Everything else in the framework composes on top of that path. @@ -48,12 +48,29 @@ That is why the same tool set works with Claude, Gemini, OpenAI, xAI, and OpenRo ### Tool execution with validation -Normal flow: - -1. `registry.execute(name, params)` -2. validate params against `inputSchema` -3. run the handler -4. return `ToolResult` +`registry.execute(name, params, context?)` preserves the ordinary result API. +`executeDetailed()` also returns exact model content and an execution receipt. +The executor captures parameters and the tool specification, validates the +selected JSON Schema dialect, resolves policy, schedules admission, persists a +start when configured, invokes the handler, normalizes its result once, checks +output, and persists the canonical receipt before delivery. + +The optional handler argument is trusted `ToolExecutionContext`. Tool arguments +cannot establish identity, scope, grant validity, or lease custody. Governed +mode requires a policy; even local mode refuses a capability requiring authority +when no policy has independently validated its binding. Discovery hints confer +no authority. + +Unclassified tools run exclusively. Reviewed `parallel-safe` tools share a +bounded FIFO scheduler; writes form barriers across callers sharing a registry. +`registry.project()` preserves execution and policy services. Trusted synthetic +delegation uses `withChildExecution()` to inherit a private reservation and +narrowed authority; a read reservation cannot become a write reservation. + +Throws, invalid output after entry, and failed result persistence preserve +unknown effect certainty. Cancellation stops admissions and reaches cooperative +handlers; a settled result is retained after late cancellation. Direct calls to +`tool.handler()` remain possible and bypass these guarantees. This same path is used by: @@ -72,7 +89,63 @@ This same path is used by: 3. clients call a tool 4. the server delegates to `registry.execute()` -The MCP layer is thin by design. The registry remains the execution engine. +The MCP layer is thin by design. `createMcpServer()` provides connect/close +lifecycle control; `startServer()` remains the stdio convenience. The executor +owns execution semantics. Discovery preserves annotations, output schemas, and +provider metadata; structured payloads retain their advertised shape, with +receipt metadata carried separately. Diagnostics use stderr. + +### Durable work and separate journals + +`SessionKernel` preserves chat and provider replay invariants. Standalone +`ExecutionJournal` and `WorkAttemptJournal` record action and attempt state +without inventing chat turns. They use protected JSONL files, sequence checks, +cross-process ownership, committed-line recovery, and fsync. Recovery reads +evidence without dispatching tools. + +Session readers accept ordered v1/v2 events. Writers emit v2; rejected or +cancelled calls need no fictitious start, but their result requires a bound +not-started receipt. Before the first v2 write, the durable store preserves the +existing bytes in a protected `.pre-v2` copy. Old binaries cannot read v2, so +downgrade requires that preserved copy, not deletion of new evidence. + +The bounded controller owns one selected attempt, rather than a model loop. +It resolves persisted grants, coordinates leases and operation identities, +executes catalog-bound steps, and records actual command/readback evidence. +Lease renewal has a private execution lane that progresses during a long step; +terminal transitions and renewals remain serialized with each other. + +Verification binds the active plan, environment, source input manifest, +fingerprints before/after execution, and mutation epoch. Relevant writes +invalidate proof. Completion rechecks current subject and authoritative coverage +and uses independent readback. Passing tests and accepted queue status remain +separate facts. Unknown effects require reconciliation; an interrupted command +is never blindly rerun because its submission response was lost. + +Trusted step bindings can supply `prepareReconciliation` and `reconcileEffect` +for an independent readback. The controller saves the input-bound readback +metadata before dispatch. Explicit reconciliation runs the read-only oracle +through the executor under current grant, catalog, path, and lease checks, then +records a separate receipt linked to the original uncertain execution. The +original receipt stays unchanged. Missing, mismatched, or inconclusive readback +keeps the attempt blocked; completion still requires fresh verification. + +Artifacts are immutable, opaque-ID outputs with verified SHA-256 and bounded +reads. The work profile uses a 32 KiB model output budget and a 64 MiB artifact +quota. Larger approved output is saved once and exposed by reference; quota +exhaustion is explicit. The framework provides policy and trusted-code scope +checks, not OS isolation from malicious plugins or programs. + +Work dispatch refreshes authoritative backend custody after queueing, and +terminal transitions recheck current grants after awaited proof readbacks. +The executor's optional `ExecutionJournalSink.onSettled` observer runs before +the scheduler releases its reservation, including after journal or pipeline +failure. The controller uses it to block queued effects as soon as an outcome +becomes uncertain. + +Hosts can opt into `createArtifactReadTool(store)` for bounded, read-only access +to opaque artifact IDs from that store. The reader verifies the stored digest +and cannot resolve arbitrary filesystem paths. ### Interactive chat @@ -292,3 +365,15 @@ Use this order: 8. add context providers when you need external systems (task management, calendars, CRM, etc.) If you keep the runtime in mind, the rest of the framework stays predictable. + +## Storage paths and acknowledgement + +`createStore(name)` uses caller `cwd/.data` at creation. An explicit +`createStore(name, { dataDir: "/previous/repository/.data" })` retains access to +old data. The runtime never copies or merges stores automatically. + +`createPgStore()` lazily loads its optional peer. Sync methods acknowledge the +cache; `await store.flush()` acknowledges queued database writes and surfaces +errors. Shutdown also flushes and reports failures. This does not provide +distributed atomic mutation or an event-store contract; durable chat/work +journals still require their own atomic file/CAS semantics. diff --git a/docs/RUNS.md b/docs/RUNS.md index 52735a5..1cf9d43 100644 --- a/docs/RUNS.md +++ b/docs/RUNS.md @@ -25,6 +25,19 @@ property contains the failed record. A manifest captures: Raw prompts, API keys, and environment secrets are not stored in a manifest. +Runs also retain `executionReceiptRefs` and optional `contextSnapshotRefs`. +Successful executions have receipt references without being mislabeled as +partial effects. Execution receipts bind tool/input/result digests, admission, +actor/scope references, policy, times, and verification state. They contain no +raw grant or lease custody and do not recursively embed the result. + +`executeDetailed()` owns the canonical result and exact model serialization. +Host text, chat history, run evidence, and standalone journals consume that +same result; output hooks run once. Known success, known failure, and unknown +effect certainty are distinct from verification. Late cancellation preserves a +valid returned result. Failed result persistence requires reconciliation even +when useful handler output is retained. + ```typescript const result = await agent.run(task, adapter, registry, context, { taskId: "task-42", @@ -88,6 +101,42 @@ const fulfillment = decideFulfillment({ Structured output proves that an answer has a requested shape. It does not, by itself, prove that an external effect occurred or that a task is fulfilled. +The work controller uses a stricter completion gate than the general run +helpers. Its current plan coverage must match locally collected evidence, +including outcome, plan/specification/environment, attempt, input fingerprint, +mutation epoch, and saved artifact digest. Later writes invalidate that proof. +Automatic completion needs a separate current grant and independent readback; +human-only or unsupported criteria route to review. Unknown operations cannot +complete until independently reconciled. + +Each claim, renewal, step, verification run, evidence submission, and terminal +transition has its own durable identity. Recovery replays a known canonical +result without another effect. A durable verification result can be submitted +without rerunning the command. An uncertain backend operation can retry only +within a verified backend key/payload guarantee; absent lookup does not prove +non-execution. Hosted Siftable recovery is currently disabled where those +contracts remain unproven. + +## Context and history + +Providers may implement `buildSnapshot()` alongside legacy `buildContext()`. +Snapshots distinguish observations, claims, inferences, and policy references; +they carry source/scope, observation time when known, digest, expiry, omissions, +and freshness. Legacy text has unknown upstream freshness. Failed retrieval is +stale or unavailable, rather than evidence that no work exists. Caches separate +provider/principal/workspace/project identities. + +Sessions capture the snapshots used by model requests and runs. Refreshes append +attributed context without rewriting prior evidence or stable system prompts. +Provider text cannot grant execution authority. Actual adapter-provided usage +and request limits are recorded; byte counts do not estimate tokens or cost. + +`historyBudget` optionally bounds messages and bytes. Projection retains whole +turns/tool exchanges and clears incompatible continuation state atomically. +An active turn exceeding the configured budget fails explicitly rather than +splitting a provider exchange or inventing a summary. Full journal evidence +remains available; the compatibility default is unbounded. + ## Capability contracts Tools may add an optional `contract` next to their schema. It describes diff --git a/docs/SIFTABLE.md b/docs/SIFTABLE.md index a13053b..4b09574 100644 --- a/docs/SIFTABLE.md +++ b/docs/SIFTABLE.md @@ -7,12 +7,13 @@ relationships, code, datasets, authority, and proof. ## Current sources of truth -This integration was reconciled against the ExecuFunction monorepo and the -published packages on 2026-08-13: +The embedded adapter uses exact development SDK `1.5.1`, inspected against its +published package on 2026-10-12. Hosted MCP and the local controller have separate +availability and recovery contracts: | Surface | Current version/state | Adapter rule | |---|---|---| -| MCP SDK | `@siftable/mcp-server` 1.2.27; 136 declared tools, 114 enabled by default | Derive schemas, feature gates, transport containment, and execution from `TOOLS`, `isToolEnabled()`, `isToolAllowedForTransport()`, and `executeTool()` | +| MCP SDK | `@siftable/mcp-server` 1.5.1 | Derive schemas, feature gates, transport containment, and execution from `TOOLS`, `isToolEnabled()`, `isToolAllowedForTransport()`, and `executeTool()`; exclude hosted sessions from the embedded path | | Ordinary CLI | `@siftable/cli` 0.5.40 | Treat as the human/operator surface; do not infer MCP parity from command parity | | Interactive TUI | Separate CLI runtime | Do not treat it as an MCP transport | | Standalone CLI repository | Distribution mirror | The monorepo package is canonical for implementation state | @@ -51,6 +52,61 @@ Use `sift tasks` for human planning and `sift work` for executable agent work. Claims, leases, dependencies, authority, verification evidence, and completion gates are execution semantics and must not be flattened into task status. +## Hosted MCP and durable-work availability + +`createSiftableMcpProvider()` uses the existing authenticated +`https://siftable.io/api/v1/mcp` boundary through the generic MCP provider. Trusted +host callbacks supply credentials. Authenticated `context_current` binds the +principal/workspace/project; model parameters cannot substitute another scope. +The default profile exposes only context and work reads. Session tools are +always excluded from the model-facing catalog, including explicit allowlists. +Their availability in the authenticated upstream catalog remains inspectable. +Evidence is disabled by default; enabling it requires an explicit host +`evidenceExecution` policy and trusted identity resolver. The governed executor +requires a validated grant and the matching supported user scope before +dispatch. A null project is not permission to guess one. + +The embedded SDK wrapper has neither a hosted principal nor session store, so +`work_session_*` tools are unavailable there. An SDK upgrade does not create +those bindings. Local durable work works independently without Siftable. + +The hosted bounded backend currently refuses construction before any claim. +The inspected 1.5.1 package does not establish the contracts needed for its +automatic recovery: + +- No supported public export supplies the canonical work-contract parser. The + internal parser behind the export map is not a supported API. +- Session open/transition/close accept keys, but public API key scope, + retention, and authoritative operation receipt lookup are not established. +- Evidence submission generates a new transport key per SDK invocation. + Stable attempt IDs alone do not establish server deduplication or conflicting + payload rejection. +- Closed-session replay compares action/key without validating a changed + payload, despite the receipt's stronger advertised duplicate behavior. + +`test-client/fixtures/siftable-work-recovery-1.5.1.json` records package integrity, +inspected source locations, exact fields, and unsupported or unproven recovery +entries. Fixtures must not invent stronger guarantees. Missing receipt lookup +is not proof of non-execution; uncertain evidence submission must not rerun its +verification command. These blockers apply to hosted durable work. Ordinary +embedded domains and authenticated MCP calls remain usable. Real hosted +interoperability and queue mutations need their own evidence and authorization. + +## Embedded result projection + +SDK `executeTool()` emits a structured value, while discovery advertises a +canonical `sift.action_receipt` wrapper. The embedded projection advertises the +supported raw value schema and keeps the full upstream receipt schema and +projection explanation in discovery metadata. It preserves schema IDs, +idempotency dispositions, omissions, and warnings across incremental callbacks. +Unsupported extraction is explicit rather than advertising an unavailable shape. + +Hosted MCP retains the full upstream structured payload and schema unchanged. +Executor receipts remain separate metadata. Validated canonical pre-handler +rejection may establish no dispatch; generic failed receipts and post-dispatch +transport errors preserve unknown certainty. Error-text prefixes alone cannot +establish that a write failed. + ## Drift prevention - The provider parity test compares its canonical tool names to the installed diff --git a/docs/TYPESCRIPT.md b/docs/TYPESCRIPT.md index 9c695bb..691312c 100644 --- a/docs/TYPESCRIPT.md +++ b/docs/TYPESCRIPT.md @@ -49,8 +49,28 @@ isolated temporary directories, and verifies: - byte-identical runtime JavaScript; and - semantically equivalent declarations after normalizing union-member order. -The verified tree currently produces 106 artifacts with byte-identical runtime -JavaScript. Four declarations differed only in union-member ordering. +The gate reports artifact counts for the actual tree. Do not reuse a historical +count as evidence for a changed build. + +## Compiled package boundaries + +`tsconfig.build.json` emits distributable source under `dist/src`, excluding +test-client and development bridges. The preparation script copies prompt +presets to `dist/system-prompts` and marks `dist/src/cli.js` executable. +`package.json` advertises explicit JavaScript/declaration subpaths; undocumented +internal paths are not public exports. + +The package root re-exports the side-effect-free framework. Source workshop +startup moved to `src/workshop.ts`; `npm start`, `dev`, and inspector scripts +retain that beginner workflow. The compiled CLI separately supports exported +MCP registries and opt-in work profiles. + +`npm run test:package` builds/packs and installs a fresh consumer, resolves all +exports and declarations, exercises presets/CLI/read-only installation, and +checks that root/core/framework imports need no Siftable or pg peer, load no +`.env`, register no tools, and create no data files. Host SDKs stay in plugin +dependencies. This test proves an installed package boundary, beyond a linked +source import. This is a compiler feedback and tooling-compatibility improvement. TypeScript types are erased, so it is not a runtime performance optimization. diff --git a/package-lock.json b/package-lock.json index 8a1fb47..0d0b1b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,22 +9,36 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "@siftable/mcp-server": "^1.2.27", - "pg": "^8.22.0" + "@modelcontextprotocol/sdk": "1.30.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" }, "bin": { - "openfunction": "src/index.ts" + "openfunction": "dist/src/cli.js" }, "devDependencies": { + "@siftable/mcp-server": "1.5.1", "@types/node": "^22.0.0", "@types/pg": "^8.20.0", "@typescript/native": "npm:typescript@7.0.2", + "pg": "8.22.0", "tsx": "^4.23.12", "typescript": "npm:@typescript/typescript6@6.0.2" }, "engines": { "node": ">=22" + }, + "peerDependencies": { + "@siftable/mcp-server": "^1.5.1", + "pg": "^8.22.0" + }, + "peerDependenciesMeta": { + "@siftable/mcp-server": { + "optional": true + }, + "pg": { + "optional": true + } } }, "node_modules/@esbuild/aix-ppc64": { @@ -485,6 +499,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "dev": true, "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -537,6 +552,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "dev": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "2.0.0", @@ -546,16 +562,147 @@ "node": ">=20" } }, + "node_modules/@sheetkit/node": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node/-/node-0.5.1.tgz", + "integrity": "sha512-lGPyFuzxGfpfvIh3gzLjFfhkWjB8lsHKKU4ZhWzW/tXPRbe+piV5SsTGlPj9b1doz04u1QV/BrXCnCfM+pniCA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "optionalDependencies": { + "@sheetkit/node-darwin-arm64": "0.5.1", + "@sheetkit/node-darwin-x64": "0.5.1", + "@sheetkit/node-linux-arm64-gnu": "0.5.1", + "@sheetkit/node-linux-arm64-musl": "0.5.1", + "@sheetkit/node-linux-x64-gnu": "0.5.1", + "@sheetkit/node-linux-x64-musl": "0.5.1", + "@sheetkit/node-win32-arm64-msvc": "0.5.1", + "@sheetkit/node-win32-x64-msvc": "0.5.1" + } + }, + "node_modules/@sheetkit/node-darwin-arm64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-darwin-arm64/-/node-darwin-arm64-0.5.1.tgz", + "integrity": "sha512-CVFDlQqvsXeS4kTsl4c+/hOfRbmioJJQvsmed+HYOtx8dRmH8YqHyOvY8oebQKF8ZAZalzTmeoY+T34nxuoT6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@sheetkit/node-darwin-x64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-darwin-x64/-/node-darwin-x64-0.5.1.tgz", + "integrity": "sha512-Z8/MRLnlqXEIPtm3bw2NcN7ULgUJpibaV7m2zdPq3fFNBkWDN+2M2Qx5J8Ny0XtMEX5/3iNi1i44Ql2Yah6/rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@sheetkit/node-linux-arm64-gnu": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-arm64-gnu/-/node-linux-arm64-gnu-0.5.1.tgz", + "integrity": "sha512-Gwxp5+53ZgH//HD8x864qO0coSW8pnCLX5WA9++k4brLr0lQV7doZcgOinaaFdseqFkCsG5LP/rTz1Lp0upGZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-linux-arm64-musl": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-arm64-musl/-/node-linux-arm64-musl-0.5.1.tgz", + "integrity": "sha512-IRO+N/okC6gouOvlHR7XaiWBGawuhrQf5oQoo8JQYth5fj/nXHPty6O4XqEYd2pfkWmXD30sk/v+T3wwJQfFeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-linux-x64-gnu": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-x64-gnu/-/node-linux-x64-gnu-0.5.1.tgz", + "integrity": "sha512-0YN+3IxMEqs70cH4BfjdbAs5V/a3UsoCREuz4SWHmMWKEMGBmwhp5yYlDbjkucnSWbNdWgeFqmFwqCiPdu3FzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-linux-x64-musl": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-x64-musl/-/node-linux-x64-musl-0.5.1.tgz", + "integrity": "sha512-AbzsY6/t9NmeymXIcojojDrIKO9KDwn0Z2OVfAuzZmWOTkijTdD26MtLSBZViPuTPJgHgntUnuTa4O2ji9DTLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-win32-arm64-msvc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-win32-arm64-msvc/-/node-win32-arm64-msvc-0.5.1.tgz", + "integrity": "sha512-/ZzkjQaQC5lffiUgXiLiGpu7zTgbpmmgql5mraXqZwLpdiGdpYDSnxAf/EfWcThc8P8P7VRp2nwuzUzLBPMUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sheetkit/node-win32-x64-msvc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-win32-x64-msvc/-/node-win32-x64-msvc-0.5.1.tgz", + "integrity": "sha512-v9+faNInkvY1HwiFlgrNpTtLna5PLDG3wIIUefDwhziYCSiinDqGPSd2DS8vhf/14KWCus9GBmQg+3A75ahWfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@siftable/mcp-server": { - "version": "1.2.27", - "resolved": "https://registry.npmjs.org/@siftable/mcp-server/-/mcp-server-1.2.27.tgz", - "integrity": "sha512-Acnes2hXnzDnfgB8B3jCB/FKvhmUuiCpGRsQjRR8DyKdt9on1Q1mdrTtHweEHONkZsJZABtJII5HRBrGZr3n/Q==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@siftable/mcp-server/-/mcp-server-1.5.1.tgz", + "integrity": "sha512-61n1Hxk42MVSpRs215Ua/V1Px+mdFoJ6K/BKEFlqnHsAK57hmMP9OwhnRGVgGRqQugKqINOVIOdgwwpurMTeJg==", + "dev": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/server": "2.0.0", - "zod": "3.22.0" + "zod": "3.25.76" }, "bin": { "exf-mcp": "dist/index.js", @@ -564,6 +711,25 @@ }, "engines": { "node": ">=20.0.0" + }, + "optionalDependencies": { + "@siftable/tabular-import": "^0.2.0" + } + }, + "node_modules/@siftable/tabular-import": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@siftable/tabular-import/-/tabular-import-0.2.0.tgz", + "integrity": "sha512-CuDqTaIPowBBjknHD56zLUNTN8v2XRn/zyU8ByUuxRBhq3vs71p/dYlbrVWE209S0bguAIRngBxnO1qL1hnRfw==", + "dev": true, + "optional": true, + "dependencies": { + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@sheetkit/node": "^0.5.1" } }, "node_modules/@types/node": { @@ -1804,6 +1970,7 @@ "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "dev": true, "license": "MIT", "dependencies": { "pg-connection-string": "^2.14.0", @@ -1831,6 +1998,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, "license": "MIT", "optional": true }, @@ -1838,12 +2006,14 @@ "version": "2.14.0", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "dev": true, "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, "license": "ISC", "engines": { "node": ">=4.0.0" @@ -1853,6 +2023,7 @@ "version": "3.14.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, "license": "MIT", "peerDependencies": { "pg": ">=8.0" @@ -1862,12 +2033,14 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "dev": true, "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, "license": "MIT", "dependencies": { "pg-int8": "1.0.1", @@ -1884,6 +2057,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, "license": "MIT", "dependencies": { "split2": "^4.1.0" @@ -1902,6 +2076,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -1911,6 +2086,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1920,6 +2096,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1929,6 +2106,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, "license": "MIT", "dependencies": { "xtend": "^4.0.0" @@ -2173,6 +2351,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, "license": "ISC", "engines": { "node": ">= 10.x" @@ -2306,10 +2485,25 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" diff --git a/package.json b/package.json index 8858651..8f98e9c 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,15 @@ "node": ">=22" }, "type": "module", - "main": "src/index.ts", + "main": "./dist/src/index.js", "bin": { - "openfunction": "./src/index.ts" + "openfunction": "./dist/src/cli.js" }, "scripts": { - "start": "tsx src/index.ts", - "dev": "tsx watch src/index.ts", - "build": "tsc", - "build:ts6": "tsc6", + "start": "tsx src/workshop.ts", + "dev": "tsx watch src/workshop.ts", + "build": "tsc -p tsconfig.build.json && node scripts/prepare-package.mjs", + "build:ts6": "tsc6 -p tsconfig.build.json && node scripts/prepare-package.mjs", "typecheck": "tsc --noEmit", "typecheck:ts6": "tsc6 --noEmit", "verify:typescript": "node scripts/verify-typescript-toolchain.mjs && node scripts/verify-typescript-parity.mjs", @@ -34,17 +34,44 @@ "test:framework": "tsx test-client/run-session-tests.ts && tsx test-client/run-framework-tests.ts && tsx test-client/run-parallel-tools-tests.ts && tsx test-client/run-legible-execution-tests.ts", "create-tool": "tsx scripts/create-tool.ts", "docs": "tsx scripts/generate-docs.ts", - "inspect": "npx @modelcontextprotocol/inspector tsx src/index.ts" + "inspect": "npx @modelcontextprotocol/inspector tsx src/workshop.ts", + "prepack": "npm run build", + "test:package": "node test-client/run-package-tests.mjs", + "test:pg-store": "tsx test-client/run-pg-store-tests.ts", + "prebuild": "node scripts/prepare-package.mjs --clean", + "prebuild:ts6": "node scripts/prepare-package.mjs --clean", + "test:validation": "tsx test-client/run-validation-tests.ts", + "test:mcp": "tsx test-client/run-mcp-tests.ts", + "test:scheduler": "tsx test-client/run-tool-scheduler-tests.ts", + "test:runtime-bridge": "tsx test-client/run-runtime-bridge-tests.ts", + "test:caller-convergence": "tsx test-client/run-caller-convergence-tests.ts", + "test:durability": "tsx test-client/run-durability-tests.ts", + "test:session-v2": "tsx test-client/run-session-v2-tests.ts", + "test:work": "tsx test-client/run-work-tests.ts", + "test:mcp-provider": "tsx test-client/run-mcp-provider-tests.ts", + "test:execution": "tsx test-client/run-execution-tests.ts", + "test:chat-execution": "tsx test-client/run-chat-execution-tests.ts", + "test:grants": "tsx test-client/run-grant-tests.ts", + "test:hermes": "node test-client/run-hermes-tests.mjs", + "test:work-cli": "npm run build && tsx test-client/run-work-cli-tests.ts", + "test:context": "tsx test-client/run-context-tests.ts", + "test:siftable-work": "tsx test-client/run-siftable-work-provider-tests.ts", + "test:providers": "npm run test:mcp-provider && npm run test:siftable-work && tsx test-client/run-siftable-provider-tests.ts", + "test:focused": "npm run test:validation && npm run test:execution && npm run test:scheduler && npm run test:runtime-bridge && npm run test:caller-convergence && npm run test:mcp && npm run test:durability && npm run test:session-v2 && npm run test:chat-execution && npm run test:context && npm run test:grants && npm run test:work && npm run test:pg-store && npm run test:providers && npm run test:hermes-config", + "test:hosts": "npm run build && npm --prefix plugins/openclaw-execufunction run verify && npm --prefix plugins/openclaw-openfunctions run validate && npm --prefix plugins/pi-openfunctions run typecheck && node test-client/run-host-plugin-tests.mjs", + "test:hermes-config": "tsx test-client/run-hermes-config-tests.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "@siftable/mcp-server": "^1.2.27", - "pg": "^8.22.0" + "@modelcontextprotocol/sdk": "1.30.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" }, "devDependencies": { - "@typescript/native": "npm:typescript@7.0.2", + "@siftable/mcp-server": "1.5.1", "@types/node": "^22.0.0", "@types/pg": "^8.20.0", + "@typescript/native": "npm:typescript@7.0.2", + "pg": "8.22.0", "tsx": "^4.23.12", "typescript": "npm:@typescript/typescript6@6.0.2" }, @@ -53,5 +80,134 @@ "@siftable/mcp-server": { "zod": "3.25.76" } + }, + "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + }, + "./framework": { + "types": "./dist/src/framework/index.d.ts", + "import": "./dist/src/framework/index.js" + }, + "./framework/core": { + "types": "./dist/src/framework/core.d.ts", + "import": "./dist/src/framework/core.js" + }, + "./framework/openclaw": { + "types": "./dist/src/framework/openclaw.d.ts", + "import": "./dist/src/framework/openclaw.js" + }, + "./framework/pi": { + "types": "./dist/src/framework/pi.d.ts", + "import": "./dist/src/framework/pi.js" + }, + "./framework/hermes": { + "types": "./dist/src/framework/hermes.d.ts", + "import": "./dist/src/framework/hermes.js" + }, + "./framework/session": { + "types": "./dist/src/framework/session.d.ts", + "import": "./dist/src/framework/session.js" + }, + "./framework/runs": { + "types": "./dist/src/framework/runs.d.ts", + "import": "./dist/src/framework/runs.js" + }, + "./framework/context": { + "types": "./dist/src/framework/context.d.ts", + "import": "./dist/src/framework/context.js" + }, + "./framework/store": { + "types": "./dist/src/framework/store.d.ts", + "import": "./dist/src/framework/store.js" + }, + "./framework/pg-store": { + "types": "./dist/src/framework/pg-store.d.ts", + "import": "./dist/src/framework/pg-store.js" + }, + "./framework/prompts": { + "types": "./dist/src/framework/prompts.d.ts", + "import": "./dist/src/framework/prompts.js" + }, + "./framework/registry": { + "types": "./dist/src/framework/registry.d.ts", + "import": "./dist/src/framework/registry.js" + }, + "./framework/tool": { + "types": "./dist/src/framework/tool.d.ts", + "import": "./dist/src/framework/tool.js" + }, + "./framework/server": { + "types": "./dist/src/framework/server.d.ts", + "import": "./dist/src/framework/server.js" + }, + "./framework/types": { + "types": "./dist/src/framework/types.d.ts", + "import": "./dist/src/framework/types.js" + }, + "./framework/adapters": { + "types": "./dist/src/framework/adapters/index.d.ts", + "import": "./dist/src/framework/adapters/index.js" + }, + "./providers/execufunction": { + "types": "./dist/src/providers/execufunction/index.d.ts", + "import": "./dist/src/providers/execufunction/index.js" + }, + "./providers/siftable": { + "types": "./dist/src/providers/execufunction/index.d.ts", + "import": "./dist/src/providers/execufunction/index.js" + }, + "./framework/artifacts": { + "types": "./dist/src/framework/artifacts.d.ts", + "import": "./dist/src/framework/artifacts.js" + }, + "./framework/execution-journal": { + "types": "./dist/src/framework/execution-journal.d.ts", + "import": "./dist/src/framework/execution-journal.js" + }, + "./framework/execution": { + "types": "./dist/src/framework/executor.d.ts", + "import": "./dist/src/framework/executor.js" + }, + "./framework/work": { + "types": "./dist/src/framework/work/index.d.ts", + "import": "./dist/src/framework/work/index.js" + }, + "./providers/mcp": { + "types": "./dist/src/providers/mcp/index.d.ts", + "import": "./dist/src/providers/mcp/index.js" + }, + "./providers/siftable-work": { + "types": "./dist/src/providers/siftable-work/index.d.ts", + "import": "./dist/src/providers/siftable-work/index.js" + }, + "./runtime/work-profile": { + "types": "./dist/src/runtime/work-profile.d.ts", + "import": "./dist/src/runtime/work-profile.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": [ + "./dist/src/workshop.js", + "./dist/src/register-tools.js", + "./dist/src/framework/env.js" + ], + "peerDependencies": { + "@siftable/mcp-server": "^1.5.1", + "pg": "^8.22.0" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + }, + "@siftable/mcp-server": { + "optional": true + } } } diff --git a/plugins/hermes-openfunctions/mcp.json b/plugins/hermes-openfunctions/mcp.json new file mode 100644 index 0000000..cc4fe78 --- /dev/null +++ b/plugins/hermes-openfunctions/mcp.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "openfunctions": { + "type": "stdio", + "command": "openfunction", + "args": ["serve", "--profile", "work", "--config", "${PLUGIN_DATA}/config.json"], + "cwd": "${PLUGIN_DATA}" + } + } +} diff --git a/plugins/hermes-openfunctions/plugin.json b/plugins/hermes-openfunctions/plugin.json new file mode 100644 index 0000000..e48b11c --- /dev/null +++ b/plugins/hermes-openfunctions/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "openfunctions", + "version": "1.0.0", + "description": "Explicit bounded durable work through the separately installed OpenFunction runtime.", + "license": "MIT", + "keywords": ["mcp", "durable-work"], + "extensions": { + "com.nousresearch.hermes": { + "servers": { "openfunctions": { "trust": "untrusted" } } + } + } +} diff --git a/plugins/hermes-openfunctions/skills/durable-work/SKILL.md b/plugins/hermes-openfunctions/skills/durable-work/SKILL.md new file mode 100644 index 0000000..242fbe9 --- /dev/null +++ b/plugins/hermes-openfunctions/skills/durable-work/SKILL.md @@ -0,0 +1,28 @@ +--- +name: durable-work +description: Perform an explicitly selected bounded work item using OpenFunction's durable controller, configured steps, trusted grants, and fresh verification evidence. +compatibility: Requires the separately installed OpenFunction executable on PATH and an isolated work configuration in this plugin's profile-scoped data directory. +--- + +Use this workflow only when the user explicitly selects a work item covered by the configured local work profile. Installing this portable plugin does not install npm dependencies or grant execution authority. Keep it disabled until the user configures the runtime and enables it. + +Before execution, use `of_work_prepare` to read the work contract and verification plan. Present the concrete scope and required evidence. A trusted user issues execution and completion grants with the compiled OpenFunction CLI against that exact preview; never fabricate authority from conversation text or a model-supplied approval field. Hermes approval permits a host call, while OpenFunction still checks persisted grants. + +The trusted user's terminal workflow is: + +```sh +openfunction work prepare --config --work-item --out +openfunction work authorize --config --preview +``` + +The authorization command displays the bound contract, scope, steps, proof methods, expiry, and limits. The user types `AUTHORIZE` for execution and separately types `COMPLETE` for automatic completion. Any other execution answer refuses; automatic completion stays off by default. This CLI is a trusted issuance path and must not become a model-callable approval tool. + +Inspect and revoke persisted grants using `openfunction work grants list --config ` and `openfunction work grants revoke --config --grant `. Revocation takes effect at the next admission and completion check; it does not reverse effects already performed. + +Use `of_work_start`, then `of_work_step` for the configured actions. Use `of_work_verify` to collect actual evidence. `of_work_status` inspects the attempt without silently executing or renewing it. Use `of_work_finish` only after the required passing evidence covers the current subject and completion authority is valid. Changed subject state requires fresh verification. + +After an interruption, inspect status and use `of_work_reconcile`. A saved known result is replayed without repeating its effect. An uncertain effect requires reconciliation and prevents automatic completion. Do not retry unknown actions or claim completion from model narration. Revoked or expired grants, lease loss, missing proof, changed scope, or a review requirement stop further admissions. + +Only actions routed through the OpenFunction executor and controller receive its receipts and recovery guarantees. Native Hermes terminal, browser, and file actions remain outside that journal. Report this coverage accurately. Keep toolsets and instructions stable during a conversation; refresh work state using tool results. + +The MCP server reads `${PLUGIN_DATA}/config.json` in caller-owned profile state. Credentials belong to the host's profile-scoped secret environment, never this manifest or chat output. The runtime must be installed and version-tested before plugin enablement; do not use an execution-time `npx` download. diff --git a/plugins/openclaw-execufunction/index.ts b/plugins/openclaw-execufunction/index.ts index ab3a8b2..4f761f4 100644 --- a/plugins/openclaw-execufunction/index.ts +++ b/plugins/openclaw-execufunction/index.ts @@ -1,24 +1,21 @@ -import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; -import { jsonResult } from "openclaw/plugin-sdk/provider-web-search"; -import { TOOLS, executeTool, isToolEnabled } from "@siftable/mcp-server"; -import { isToolAllowedForTransport } from "@siftable/mcp-server/factory"; +import { Type } from "typebox"; +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { ToolRegistry } from "openfunction/framework/registry"; +import { toOpenclawTools } from "openfunction/framework/openclaw"; +import { createSiftableSdkTools } from "openfunction/providers/siftable"; import { ExfClient } from "./src/client.js"; - -type SiftableSdkTool = { - name: string; - description: string; - inputSchema: unknown; - annotations?: { title?: string }; -}; +import { buildLocalWorkTools } from "./src/work-tools.js"; const SIFTABLE_DESCRIPTION = "Siftable is a shared, evidence-backed work graph. Human planning and executable " + "agent work stay distinct while projects, knowledge, relationships, datasets, " + "governed actions, and verification remain connected."; -function executionFailure(text: string): string | undefined { - const trimmed = text.trim(); - return trimmed.startsWith("Error:") ? trimmed.slice("Error:".length).trim() : undefined; +/** Credential-free discovery; the client is touched only inside a dispatched handler. */ +export function buildSiftableRegistry(client: Parameters[0]): ToolRegistry { + const registry = new ToolRegistry(); + registry.registerAll(createSiftableSdkTools(client, { transportProfile: "hosted_remote" })); + return registry; } export default definePluginEntry({ @@ -26,39 +23,33 @@ export default definePluginEntry({ name: "Siftable", description: SIFTABLE_DESCRIPTION, register(api) { - // Defer client construction until a tool is actually called. - // This allows the plugin to load even before the user sets EXF_PAT. let cachedClient: ExfClient | undefined; - - const clientProxy = new Proxy({} as ExfClient, { - get(_target, prop, receiver) { - if (!cachedClient) { - cachedClient = new ExfClient(api.config); - } - const value = Reflect.get(cachedClient, prop, receiver); - return typeof value === "function" ? value.bind(cachedClient) : value; + const clientProxy = { + raw() { + cachedClient ??= new ExfClient(api.config); + return cachedClient.raw(); }, - }); - - const tools = TOOLS as unknown as readonly SiftableSdkTool[]; - for (const definition of tools) { - if (!isToolEnabled(definition.name)) continue; - if (!isToolAllowedForTransport(definition.name, "hosted_remote")) continue; - + }; + const registry = buildSiftableRegistry(clientProxy); + const settings = api.config.plugins?.entries?.execufunction?.config; + const configPath = settings && typeof settings.workConfigPath === "string" + ? settings.workConfigPath : process.env.OPENFUNCTION_WORK_CONFIG; + const work = buildLocalWorkTools(configPath); + registry.registerAll(work.tools); + api.registerService({ id: "openfunction-work", start() {}, stop: () => work.shutdown() }); + for (const tool of toOpenclawTools(registry, { + label: (definition) => definition.annotations?.title ?? `Siftable: ${definition.name}`, + })) { + // Mixed plugin registration consumes the real pinned AnyAgentTool + // contract; no cast hides drift in schemas, content or native signals. + if (!tool.parameters || typeof tool.parameters !== "object") throw new Error(`Invalid schema for ${tool.name}`); api.registerTool({ - name: definition.name, - label: definition.annotations?.title ?? `Siftable: ${definition.name}`, - description: definition.description, - parameters: definition.inputSchema, - execute: async (_toolCallId: string, params: Record) => { - const text = await executeTool(clientProxy.raw(), definition.name, params, { - transportProfile: "hosted_remote", - }); - const failure = executionFailure(text); - if (failure) throw new Error(failure); - return jsonResult(text); + ...tool, label: tool.label ?? tool.name, parameters: Type.Unsafe(tool.parameters), + async execute(toolCallId, params, signal) { + const result = await tool.execute(toolCallId, params, signal); + return { ...result, details: result.details ?? {} }; }, - } as AnyAgentTool); + }); } }, }); diff --git a/plugins/openclaw-execufunction/openclaw.plugin.json b/plugins/openclaw-execufunction/openclaw.plugin.json index 85a7204..a41ec5f 100644 --- a/plugins/openclaw-execufunction/openclaw.plugin.json +++ b/plugins/openclaw-execufunction/openclaw.plugin.json @@ -37,124 +37,136 @@ "workspaceId": { "label": "Workspace ID", "help": "Optional workspace ID for multi-workspace accounts." + }, + "workConfigPath": { + "label": "Local work profile", + "help": "Absolute trusted profile path. Local work does not require a Siftable token." } }, "contracts": { "tools": [ - "ai_models_list", + "agent_create", + "agent_disable", + "agent_get", + "agent_list", + "agent_update", + "agent_work_list", "ai_connection_status", "ai_generate", + "ai_models_list", "ai_usage_get", "approval_request", "approval_status", - "capability_list", + "calendar_create_event", + "calendar_delete_event", + "calendar_list_events", + "calendar_update_event", "capability_describe", "capability_execute", + "capability_list", + "code_memory_delete", + "code_memory_detect_stale", + "code_memory_events", + "code_memory_get", + "code_memory_list", + "code_memory_mark_needs_review", + "code_memory_search", + "code_memory_store", + "crm_import_plan_apply", + "crm_import_plan_approve", + "crm_import_plan_create", + "crm_import_plan_get", + "entity_graph", "execution_grant_adapter_list", "execution_grant_request", "execution_grant_status", - "vault_materialization_request", - "vault_materialization_status", - "relationship_contact_plan_get", - "project_list", - "project_create", - "project_update", + "lead_discovery_candidate_list", + "lead_discovery_candidate_review", + "lead_discovery_candidate_review_many", + "lead_discovery_company_prepare", + "lead_discovery_company_run", + "lead_discovery_company_search", + "lead_discovery_import_plan_create", + "lead_discovery_import_plan_lookup", + "lead_discovery_origin_get", + "lead_discovery_run_get", + "note_bulk_delete", + "note_create", + "note_delete", + "note_get", + "note_list", + "note_search", + "note_update", + "of_work_finish", + "of_work_prepare", + "of_work_reconcile", + "of_work_start", + "of_work_status", + "of_work_step", + "of_work_verify", + "organization_bulk_delete", + "organization_create", + "organization_delete", + "organization_enrichment_availability", + "organization_enrichment_preview", + "organization_search", + "organization_update", + "people_bulk_delete", + "people_search", + "person_create", + "person_delete", + "person_enrichment_apply", + "person_enrichment_availability", + "person_enrichment_run", + "person_update", "project_archive", + "project_create", "project_get_context", + "project_list", "project_planning_get", "project_planning_recompute", - "task_list", - "task_create", - "task_get", - "task_update", - "task_planning_update", - "task_coupling_edge_list", + "project_update", + "relationship_contact_plan_get", + "repository_project_link", + "schema_introspect", + "task_bulk_delete", + "task_complete", "task_coupling_edge_create", "task_coupling_edge_delete", + "task_coupling_edge_list", + "task_create", "task_delete", - "task_bulk_delete", - "task_complete", - "agent_list", - "agent_get", - "agent_create", - "agent_update", - "agent_disable", - "agent_work_list", - "work_item_list", - "work_item_get", - "work_item_verification_plan_get", - "work_item_revision_history", - "work_item_verification_history", - "work_item_verification_evidence_submit", - "work_item_create", - "work_item_edit", - "work_item_dependencies_replace", - "work_item_claim", - "work_dependency_policy_get", - "work_dependency_policy_update", - "work_item_start", - "work_item_heartbeat", - "work_item_block", - "work_item_review", - "work_item_complete", - "work_item_fail", - "work_item_release", - "work_item_cancel", - "work_item_requeue", - "note_search", - "note_list", - "note_get", - "note_create", - "note_update", - "note_delete", - "note_bulk_delete", - "entity_graph", - "schema_introspect", - "people_search", - "person_create", - "person_update", - "person_delete", - "people_bulk_delete", - "organization_create", - "organization_search", - "organization_update", - "organization_delete", - "organization_bulk_delete", - "calendar_list_events", - "calendar_create_event", - "calendar_update_event", - "calendar_delete_event", - "codebase_list", - "codebase_register", - "codebase_status", - "codebase_snapshot_status", - "codebase_materialize_snapshot", - "codebase_index", - "codebase_index_incremental", - "codebase_delete", - "code_who_knows", - "code_compute_expertise", - "code_history", - "git_blame_symbol", - "task_link_code", - "code_memory_store", - "code_memory_search", - "code_memory_list", - "code_memory_get", - "code_memory_events", - "code_memory_confirm", - "code_memory_reject", - "code_memory_edit", - "code_memory_supersede", - "code_memory_mark_needs_review", - "code_memory_detect_stale", - "code_memory_delete", + "task_get", + "task_list", + "task_planning_update", + "task_update", "upload_document", - "vault_list", + "vault_audit", "vault_create", - "vault_update", + "vault_list", + "vault_materialization_request", + "vault_materialization_status", "vault_search", - "vault_audit" + "vault_update", + "work_dependency_policy_get", + "work_dependency_policy_update", + "work_item_collaboration_cursor_acknowledge", + "work_item_collaboration_event_list", + "work_item_collaboration_event_send", + "work_item_collaboration_event_wait", + "work_item_collaboration_participant_add", + "work_item_collaboration_participant_list", + "work_item_collaboration_participant_remove", + "work_item_create", + "work_item_dependencies_replace", + "work_item_edit", + "work_item_get", + "work_item_list", + "work_item_requeue", + "work_item_revision_history", + "work_item_verification_evidence_submit", + "work_item_verification_history", + "work_item_verification_plan_get" ] }, "configSchema": { @@ -172,6 +184,10 @@ }, "workspaceId": { "type": "string" + }, + "workConfigPath": { + "type": "string", + "description": "Absolute path to a trusted local OpenFunction work profile; grants are issued separately with the OpenFunction CLI." } } } diff --git a/plugins/openclaw-execufunction/package-lock.json b/plugins/openclaw-execufunction/package-lock.json index 81ac4d1..f33fac1 100644 --- a/plugins/openclaw-execufunction/package-lock.json +++ b/plugins/openclaw-execufunction/package-lock.json @@ -9,15 +9,55 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "@siftable/mcp-server": "^1.2.27", + "@siftable/mcp-server": "1.5.1", "typebox": "^1.3.7" }, "devDependencies": { "@typescript/native": "npm:typescript@7.0.2", - "openclaw": "2026.7.1-2" + "openclaw": "2026.7.1-2", + "openfunction": "file:../.." }, "peerDependencies": { - "openclaw": ">=2026.5.17" + "openclaw": ">=2026.5.17", + "openfunction": "^1.0.0" + } + }, + "../..": { + "name": "openfunction", + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + }, + "bin": { + "openfunction": "dist/src/cli.js" + }, + "devDependencies": { + "@siftable/mcp-server": "1.5.1", + "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", + "@typescript/native": "npm:typescript@7.0.2", + "pg": "8.22.0", + "tsx": "^4.23.12", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@siftable/mcp-server": "^1.5.1", + "pg": "^8.22.0" + }, + "peerDependenciesMeta": { + "@siftable/mcp-server": { + "optional": true + }, + "pg": { + "optional": true + } } }, "node_modules/@hono/node-server": { @@ -133,16 +173,137 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@sheetkit/node": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node/-/node-0.5.1.tgz", + "integrity": "sha512-lGPyFuzxGfpfvIh3gzLjFfhkWjB8lsHKKU4ZhWzW/tXPRbe+piV5SsTGlPj9b1doz04u1QV/BrXCnCfM+pniCA==", + "license": "MIT OR Apache-2.0", + "optional": true, + "optionalDependencies": { + "@sheetkit/node-darwin-arm64": "0.5.1", + "@sheetkit/node-darwin-x64": "0.5.1", + "@sheetkit/node-linux-arm64-gnu": "0.5.1", + "@sheetkit/node-linux-arm64-musl": "0.5.1", + "@sheetkit/node-linux-x64-gnu": "0.5.1", + "@sheetkit/node-linux-x64-musl": "0.5.1", + "@sheetkit/node-win32-arm64-msvc": "0.5.1", + "@sheetkit/node-win32-x64-msvc": "0.5.1" + } + }, + "node_modules/@sheetkit/node-darwin-arm64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-darwin-arm64/-/node-darwin-arm64-0.5.1.tgz", + "integrity": "sha512-CVFDlQqvsXeS4kTsl4c+/hOfRbmioJJQvsmed+HYOtx8dRmH8YqHyOvY8oebQKF8ZAZalzTmeoY+T34nxuoT6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@sheetkit/node-darwin-x64": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-darwin-x64/-/node-darwin-x64-0.5.1.tgz", + "integrity": "sha512-Z8/MRLnlqXEIPtm3bw2NcN7ULgUJpibaV7m2zdPq3fFNBkWDN+2M2Qx5J8Ny0XtMEX5/3iNi1i44Ql2Yah6/rg==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@sheetkit/node-linux-arm64-gnu": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-arm64-gnu/-/node-linux-arm64-gnu-0.5.1.tgz", + "integrity": "sha512-Gwxp5+53ZgH//HD8x864qO0coSW8pnCLX5WA9++k4brLr0lQV7doZcgOinaaFdseqFkCsG5LP/rTz1Lp0upGZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-linux-arm64-musl": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-arm64-musl/-/node-linux-arm64-musl-0.5.1.tgz", + "integrity": "sha512-IRO+N/okC6gouOvlHR7XaiWBGawuhrQf5oQoo8JQYth5fj/nXHPty6O4XqEYd2pfkWmXD30sk/v+T3wwJQfFeg==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-linux-x64-gnu": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-x64-gnu/-/node-linux-x64-gnu-0.5.1.tgz", + "integrity": "sha512-0YN+3IxMEqs70cH4BfjdbAs5V/a3UsoCREuz4SWHmMWKEMGBmwhp5yYlDbjkucnSWbNdWgeFqmFwqCiPdu3FzA==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-linux-x64-musl": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-linux-x64-musl/-/node-linux-x64-musl-0.5.1.tgz", + "integrity": "sha512-AbzsY6/t9NmeymXIcojojDrIKO9KDwn0Z2OVfAuzZmWOTkijTdD26MtLSBZViPuTPJgHgntUnuTa4O2ji9DTLg==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sheetkit/node-win32-arm64-msvc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-win32-arm64-msvc/-/node-win32-arm64-msvc-0.5.1.tgz", + "integrity": "sha512-/ZzkjQaQC5lffiUgXiLiGpu7zTgbpmmgql5mraXqZwLpdiGdpYDSnxAf/EfWcThc8P8P7VRp2nwuzUzLBPMUog==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sheetkit/node-win32-x64-msvc": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sheetkit/node-win32-x64-msvc/-/node-win32-x64-msvc-0.5.1.tgz", + "integrity": "sha512-v9+faNInkvY1HwiFlgrNpTtLna5PLDG3wIIUefDwhziYCSiinDqGPSd2DS8vhf/14KWCus9GBmQg+3A75ahWfg==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@siftable/mcp-server": { - "version": "1.2.27", - "resolved": "https://registry.npmjs.org/@siftable/mcp-server/-/mcp-server-1.2.27.tgz", - "integrity": "sha512-Acnes2hXnzDnfgB8B3jCB/FKvhmUuiCpGRsQjRR8DyKdt9on1Q1mdrTtHweEHONkZsJZABtJII5HRBrGZr3n/Q==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@siftable/mcp-server/-/mcp-server-1.5.1.tgz", + "integrity": "sha512-61n1Hxk42MVSpRs215Ua/V1Px+mdFoJ6K/BKEFlqnHsAK57hmMP9OwhnRGVgGRqQugKqINOVIOdgwwpurMTeJg==", "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "@modelcontextprotocol/server": "2.0.0", - "zod": "3.22.0" + "zod": "3.25.76" }, "bin": { "exf-mcp": "dist/index.js", @@ -151,6 +312,24 @@ }, "engines": { "node": ">=20.0.0" + }, + "optionalDependencies": { + "@siftable/tabular-import": "^0.2.0" + } + }, + "node_modules/@siftable/tabular-import": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@siftable/tabular-import/-/tabular-import-0.2.0.tgz", + "integrity": "sha512-CuDqTaIPowBBjknHD56zLUNTN8v2XRn/zyU8ByUuxRBhq3vs71p/dYlbrVWE209S0bguAIRngBxnO1qL1hnRfw==", + "optional": true, + "dependencies": { + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@sheetkit/node": "^0.5.1" } }, "node_modules/@typescript/native": { @@ -5204,6 +5383,10 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/openfunction": { + "resolved": "../..", + "link": true + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5567,6 +5750,19 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/plugins/openclaw-execufunction/package.json b/plugins/openclaw-execufunction/package.json index 921ab32..75eb534 100644 --- a/plugins/openclaw-execufunction/package.json +++ b/plugins/openclaw-execufunction/package.json @@ -1,7 +1,7 @@ { "name": "@openfunctions/openclaw-execufunction", "version": "0.3.0", - "description": "Siftable plugin for OpenClaw — a shared graph for human planning, agent work, evidence, and governed action", + "description": "Siftable plugin for OpenClaw \u2014 a shared graph for human planning, agent work, evidence, and governed action", "type": "module", "license": "MIT", "keywords": [ @@ -21,24 +21,27 @@ }, "files": [ "dist/", - "openclaw.plugin.json" + "openclaw.plugin.json", + "tool-contracts.json" ], "scripts": { "build": "node scripts/clean.mjs && tsc", "typecheck": "tsc --noEmit", - "sync-contract": "node scripts/sync-contract.mjs", - "verify": "npm run sync-contract && npm run build && node scripts/verify-contract.mjs" + "sync-contract": "npm run build && node scripts/sync-contract.mjs", + "verify": "npm run build && node scripts/verify-contract.mjs" }, "dependencies": { - "@siftable/mcp-server": "^1.2.27", + "@siftable/mcp-server": "1.5.1", "typebox": "^1.3.7" }, "peerDependencies": { - "openclaw": ">=2026.5.17" + "openclaw": ">=2026.5.17", + "openfunction": "^1.0.0" }, "devDependencies": { "openclaw": "2026.7.1-2", - "@typescript/native": "npm:typescript@7.0.2" + "@typescript/native": "npm:typescript@7.0.2", + "openfunction": "file:../.." }, "overrides": { "@siftable/mcp-server": { diff --git a/plugins/openclaw-execufunction/scripts/capture-contracts.mjs b/plugins/openclaw-execufunction/scripts/capture-contracts.mjs new file mode 100644 index 0000000..51f2445 --- /dev/null +++ b/plugins/openclaw-execufunction/scripts/capture-contracts.mjs @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + return value; +} +export function schemaDigest(schema) { + return createHash('sha256').update(JSON.stringify(canonical(schema))).digest('hex'); +} +export async function captureContracts() { + const { default: plugin } = await import('../dist/index.js'); + const tools = []; + plugin.register({ config: {}, registerTool(tool) { tools.push(tool); }, registerService() {} }); + assert.equal(new Set(tools.map((tool) => tool.name)).size, tools.length, 'Duplicate runtime tool names'); + assert.ok(tools.every((tool) => !tool.name.startsWith('work_session_')), 'Embedded sessions need a validated principal and store'); + const version = async (name, entry) => { + let directory = dirname(fileURLToPath(import.meta.resolve(entry))); + for (;;) { + try { + const pkg = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (pkg.name === name) return pkg.version; + } catch (error) { if (error.code !== 'ENOENT') throw error; } + const parent = dirname(directory); + if (parent === directory) throw new Error(`Package version unavailable for ${name}`); + directory = parent; + } + }; + return { + schemaVersion: 1, + sdk: { openclaw: await version('openclaw', 'openclaw/plugin-sdk/plugin-entry'), siftable: await version('@siftable/mcp-server', '@siftable/mcp-server/exfClient'), openfunction: await version('openfunction', 'openfunction/framework/registry') }, + tools: tools.map((tool) => ({ name: tool.name, inputSchemaDigest: schemaDigest(tool.parameters) })).sort((a,b) => a.name.localeCompare(b.name)), + }; +} diff --git a/plugins/openclaw-execufunction/scripts/sync-contract.mjs b/plugins/openclaw-execufunction/scripts/sync-contract.mjs index 6717499..389e924 100644 --- a/plugins/openclaw-execufunction/scripts/sync-contract.mjs +++ b/plugins/openclaw-execufunction/scripts/sync-contract.mjs @@ -1,12 +1,9 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { TOOLS, isToolEnabled } from "@siftable/mcp-server"; -import { isToolAllowedForTransport } from "@siftable/mcp-server/factory"; - -const manifestUrl = new URL("../openclaw.plugin.json", import.meta.url); -const manifest = JSON.parse(await readFile(manifestUrl, "utf8")); -manifest.contracts.tools = TOOLS - .filter((tool) => isToolEnabled(tool.name)) - .filter((tool) => isToolAllowedForTransport(tool.name, "hosted_remote")) - .map((tool) => tool.name); +import { readFile, writeFile } from 'node:fs/promises'; +import { captureContracts } from './capture-contracts.mjs'; +const contract = await captureContracts(); +const manifestUrl = new URL('../openclaw.plugin.json', import.meta.url); +const manifest = JSON.parse(await readFile(manifestUrl, 'utf8')); +manifest.contracts.tools = contract.tools.map((tool) => tool.name); await writeFile(manifestUrl, `${JSON.stringify(manifest, null, 2)}\n`); -process.stdout.write(`Synced ${manifest.contracts.tools.length} Siftable tool contracts.\n`); +await writeFile(new URL('../tool-contracts.json', import.meta.url), `${JSON.stringify(contract, null, 2)}\n`); +process.stdout.write(`Synced ${contract.tools.length} runtime names and schema digests.\n`); diff --git a/plugins/openclaw-execufunction/scripts/verify-contract.mjs b/plugins/openclaw-execufunction/scripts/verify-contract.mjs index a4b21a4..95eea41 100644 --- a/plugins/openclaw-execufunction/scripts/verify-contract.mjs +++ b/plugins/openclaw-execufunction/scripts/verify-contract.mjs @@ -1,25 +1,9 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; - -const manifest = JSON.parse( - await readFile(new URL("../openclaw.plugin.json", import.meta.url), "utf8"), -); -const { default: plugin } = await import("../dist/index.js"); -const registered = []; - -plugin.register({ - config: {}, - registerTool(tool) { - registered.push(tool.name); - }, -}); - -assert.deepEqual( - registered.toSorted(), - [...manifest.contracts.tools].toSorted(), - "openclaw.plugin.json contracts.tools must match runtime registrations", -); - -process.stdout.write( - `Verified ${registered.length} Siftable OpenClaw tool contracts.\n`, -); +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { captureContracts } from './capture-contracts.mjs'; +const manifest = JSON.parse(await readFile(new URL('../openclaw.plugin.json', import.meta.url), 'utf8')); +const saved = JSON.parse(await readFile(new URL('../tool-contracts.json', import.meta.url), 'utf8')); +const actual = await captureContracts(); +assert.deepEqual(manifest.contracts.tools.toSorted(), actual.tools.map((tool) => tool.name).toSorted(), 'Manifest names differ from credential-free runtime registration'); +assert.deepEqual(saved, actual, 'Runtime schema digests or pinned SDK versions differ from the saved snapshot'); +process.stdout.write(`Verified ${actual.tools.length} Siftable OpenClaw names and schema digests.\n`); diff --git a/plugins/openclaw-execufunction/src/client.ts b/plugins/openclaw-execufunction/src/client.ts index a9bb0ba..cd4045a 100644 --- a/plugins/openclaw-execufunction/src/client.ts +++ b/plugins/openclaw-execufunction/src/client.ts @@ -256,17 +256,11 @@ export class ExfClient { // --- Codebase --- - async searchCode(query: string, repositoryId?: string) { - return unwrap( - await this.sift.searchCode({ query, repositoryId }), - "searchCode", - ); + async searchCode(_query: string, _repositoryId?: string) { + throw new Error("searchCode is unavailable in Siftable SDK 1.5.1; use the advertised hosted MCP code capabilities."); } - async codeWhoKnows(repositoryId: string, area: string) { - return unwrap( - await this.sift.whoKnows(repositoryId, area), - "whoKnows", - ); + async codeWhoKnows(_repositoryId: string, _area: string) { + throw new Error("whoKnows is unavailable in Siftable SDK 1.5.1; use the advertised hosted MCP code capabilities."); } } diff --git a/plugins/openclaw-execufunction/src/work-tools.ts b/plugins/openclaw-execufunction/src/work-tools.ts new file mode 100644 index 0000000..df2405a --- /dev/null +++ b/plugins/openclaw-execufunction/src/work-tools.ts @@ -0,0 +1,25 @@ +/** Fixed local work surface; profile loading follows only trusted plugin configuration. */ +import { createWorkTools, type WorkAttemptController } from "openfunction/framework/work"; +import { createWorkProfile, type WorkProfile } from "openfunction/runtime/work-profile"; + +export function buildLocalWorkTools(configPath?: string) { + let runtime: Promise | undefined; + const controller = async () => { + if (!configPath) throw new Error("Configure workConfigPath or OPENFUNCTION_WORK_CONFIG before using local work tools"); + runtime ??= createWorkProfile(configPath); + return (await runtime).controller; + }; + const bound = { + prepare: async (...args: Parameters) => (await controller()).prepare(...args), + start: async (...args: Parameters) => (await controller()).start(...args), + step: async (...args: Parameters) => (await controller()).step(...args), + verify: async (...args: Parameters) => (await controller()).verify(...args), + status: async (...args: Parameters) => (await controller()).status(...args), + reconcile: async (...args: Parameters) => (await controller()).reconcile(...args), + finish: async (...args: Parameters) => (await controller()).finish(...args), + }; + return { + tools: createWorkTools(bound), + async shutdown() { if (runtime) await (await runtime).controller.shutdown(); }, + }; +} diff --git a/plugins/openclaw-execufunction/tool-contracts.json b/plugins/openclaw-execufunction/tool-contracts.json new file mode 100644 index 0000000..bcd7b77 --- /dev/null +++ b/plugins/openclaw-execufunction/tool-contracts.json @@ -0,0 +1,498 @@ +{ + "schemaVersion": 1, + "sdk": { + "openclaw": "2026.7.1-2", + "siftable": "1.5.1", + "openfunction": "1.0.0" + }, + "tools": [ + { + "name": "agent_create", + "inputSchemaDigest": "2ec2c8117542cd5531d7667beea525dbbff88cdea778c416d864d197bb457a3d" + }, + { + "name": "agent_disable", + "inputSchemaDigest": "d7c9bd59e6f4e7e68156ce425632ded3382563a9c8c57667ec53614ca833f458" + }, + { + "name": "agent_get", + "inputSchemaDigest": "d7c9bd59e6f4e7e68156ce425632ded3382563a9c8c57667ec53614ca833f458" + }, + { + "name": "agent_list", + "inputSchemaDigest": "6a9e8c82e01579de1d248823c396e107927ed12c74da40960a41a4f48ca8c62d" + }, + { + "name": "agent_update", + "inputSchemaDigest": "bd0de3ae9a4c6645ae0c1f8428428fcafa1e22262d9820aeeb4f7e918d5ccc42" + }, + { + "name": "agent_work_list", + "inputSchemaDigest": "9e5d9f539bbe53d5b963d06107591009f50d08a68959f31d49e0037a38d38b57" + }, + { + "name": "ai_connection_status", + "inputSchemaDigest": "050e7f3532d2959993787461d9a6534e88c3329f1d700dae00cc0691087e34bd" + }, + { + "name": "ai_generate", + "inputSchemaDigest": "ba685cbcabe67b8ed9a3ad92f1a79bddd86d417f89bfda5e8e9a9a25b326d74c" + }, + { + "name": "ai_models_list", + "inputSchemaDigest": "d746974fa9afd5e951f76f9af38954b0ad7f436f2120dc974da65e5ee39f856f" + }, + { + "name": "ai_usage_get", + "inputSchemaDigest": "957ff444091f6b957902c7be419b32134298a4cb55c88f72eb9522cca7ad734c" + }, + { + "name": "approval_request", + "inputSchemaDigest": "701d83f6a0f784826a01646c3c91f06778b7f080f5aa247b92dd4dc13e8cf0be" + }, + { + "name": "approval_status", + "inputSchemaDigest": "158e0aa7ebd8436a140b21c14603b0310e8b913a858f4639352147694d6b16f5" + }, + { + "name": "calendar_create_event", + "inputSchemaDigest": "6308e64e54feb7818e091377cf97700ff4ae9d49c31f0de2921996fb16227ac3" + }, + { + "name": "calendar_delete_event", + "inputSchemaDigest": "103be8c212cabf6d6958391088ba28933fade9c186bb13be19221619d6e7a908" + }, + { + "name": "calendar_list_events", + "inputSchemaDigest": "c7adad9006ce18a8c123b7dc181079aca2904c0910f1155c45f2d3e370853f8d" + }, + { + "name": "calendar_update_event", + "inputSchemaDigest": "96f62bd731bfeebcd00254a54bca70b409ba1c6d2a8c9ca349f6fe2dbfda46b8" + }, + { + "name": "capability_describe", + "inputSchemaDigest": "73c7d93b7f2d8078e34be90fe543130e4fc82580992176056b81614e26c3c2ec" + }, + { + "name": "capability_execute", + "inputSchemaDigest": "f09d86e942868f06038c0819ce28dca6ba8cc9a37c6d228eaa78f2108c3b3521" + }, + { + "name": "capability_list", + "inputSchemaDigest": "99334726611ccf58a148b0814696bfa6fe08c1b2d027e946beccf5a74331c9aa" + }, + { + "name": "code_memory_delete", + "inputSchemaDigest": "a7aed0b2ecb33b35d08cac4f19dcf51d99d20a12968f24aa739df724a558fc4d" + }, + { + "name": "code_memory_detect_stale", + "inputSchemaDigest": "5b34e3c96e311501ed12d76966f1aef1e670b68a308bfc1df5c8a4d64fb845ab" + }, + { + "name": "code_memory_events", + "inputSchemaDigest": "b7ec5909174fa44a86cb91441f484d6e0c6ba51525a66a8e329c27eb0a4d83f9" + }, + { + "name": "code_memory_get", + "inputSchemaDigest": "a7aed0b2ecb33b35d08cac4f19dcf51d99d20a12968f24aa739df724a558fc4d" + }, + { + "name": "code_memory_list", + "inputSchemaDigest": "c7f34d3256be47c553c2b06f847ff762d1acedd6f5ed935c46d757f9acfd8d91" + }, + { + "name": "code_memory_mark_needs_review", + "inputSchemaDigest": "ef4649cffa6e3475608e3c86d4a1ab2e33787e622583eb646d6aecc66261ba8b" + }, + { + "name": "code_memory_search", + "inputSchemaDigest": "077a3027c7371602e6b75e11bbef4e3454e31f85db9b5cc97e50edec33c011c7" + }, + { + "name": "code_memory_store", + "inputSchemaDigest": "9d402d8358caaacee1deb68e1b6c49b8124b4703b34a0b24fc1ad1386dbaf47d" + }, + { + "name": "crm_import_plan_apply", + "inputSchemaDigest": "f4ae962285b014478b4324990f9552667d54a7c043f9339ae6e6187c50ed7f1d" + }, + { + "name": "crm_import_plan_approve", + "inputSchemaDigest": "433206da5a3c928468285c8239b541e6741ddf857d466df89b9776eaab2e652a" + }, + { + "name": "crm_import_plan_create", + "inputSchemaDigest": "dbe4cacbcb6ab238d11ac21e0ae88bf28268ad9c970e3eee9f0d382d53ef8468" + }, + { + "name": "crm_import_plan_get", + "inputSchemaDigest": "f4ae962285b014478b4324990f9552667d54a7c043f9339ae6e6187c50ed7f1d" + }, + { + "name": "entity_graph", + "inputSchemaDigest": "999d2b1fdc80ac3eaf1e12c6357390804cec64ef02dfbe3b699937a1232da6a3" + }, + { + "name": "execution_grant_adapter_list", + "inputSchemaDigest": "99334726611ccf58a148b0814696bfa6fe08c1b2d027e946beccf5a74331c9aa" + }, + { + "name": "execution_grant_request", + "inputSchemaDigest": "c9d4b1db08452af2865c8e6bc3ccd368a9b616991a745dc961d9b5d7995fbc7e" + }, + { + "name": "execution_grant_status", + "inputSchemaDigest": "0979270fdaf2b129f36dcd0cb9e8e41aeac9ee1ed9e52cb5c2cd17f08a0f70b1" + }, + { + "name": "lead_discovery_candidate_list", + "inputSchemaDigest": "4de3bc8a6424f108edb53f6143965ef46e6c2ca96068db0f9f49212a871fd0f7" + }, + { + "name": "lead_discovery_candidate_review", + "inputSchemaDigest": "42f809d4e258fdaa4e26888e84aa1b468aa96a7790be9d0a077b589ba10ddb77" + }, + { + "name": "lead_discovery_candidate_review_many", + "inputSchemaDigest": "5f871259d548be41d133a5ee4f2f1e7012ea1cd2646f0485d8f468f4225b7dbe" + }, + { + "name": "lead_discovery_company_prepare", + "inputSchemaDigest": "b058971a086c936ebf5967f84552487f1c382ee86e76964267dc07321df90e48" + }, + { + "name": "lead_discovery_company_run", + "inputSchemaDigest": "596cad086e3c97170865ede09ed5cf64fe4b7790421b6a4429a0a0eceaa0ed43" + }, + { + "name": "lead_discovery_company_search", + "inputSchemaDigest": "615f4da89e576ee72bb6d4b209c8ee84d4e78465c7343c59dbc187df6f7d9f75" + }, + { + "name": "lead_discovery_import_plan_create", + "inputSchemaDigest": "858f241927131a4a27ddc3db8bc296eab6f184fbc24aa2972168f462fc345cd0" + }, + { + "name": "lead_discovery_import_plan_lookup", + "inputSchemaDigest": "d1c7601c22b43c56d8b343c4c05456180c2758a40c05e614834cf569aec21e68" + }, + { + "name": "lead_discovery_origin_get", + "inputSchemaDigest": "f8f134361ef99724acbbe74ef4f3fd00d6e8e3d85a69cf3f778520096c726285" + }, + { + "name": "lead_discovery_run_get", + "inputSchemaDigest": "4de3bc8a6424f108edb53f6143965ef46e6c2ca96068db0f9f49212a871fd0f7" + }, + { + "name": "note_bulk_delete", + "inputSchemaDigest": "7048d638f6a1267b6ff4f8290ff77a426a02f9e1e29f28aee950e4b7392b29da" + }, + { + "name": "note_create", + "inputSchemaDigest": "9d493523f48346613dbefaadb17ccbe9ee25f759a12ae68210b1cbaea5ea03cd" + }, + { + "name": "note_delete", + "inputSchemaDigest": "c8ac7c2fe01330209246469f16651674b8b24741d5df76454c5857d5e4c25bfc" + }, + { + "name": "note_get", + "inputSchemaDigest": "c8ac7c2fe01330209246469f16651674b8b24741d5df76454c5857d5e4c25bfc" + }, + { + "name": "note_list", + "inputSchemaDigest": "7c0d81b08b787ae98994367dcca1f6a1384fb9450223809595b9950a8fe8e727" + }, + { + "name": "note_search", + "inputSchemaDigest": "1e6aaf220292b7fad788edbe9e5d3ba5d4a610bc2e511454237504c4eb730e45" + }, + { + "name": "note_update", + "inputSchemaDigest": "bb5ff84a2f7df431d97b30c6636745e53bd452daaa7481eaf617578218baf370" + }, + { + "name": "of_work_finish", + "inputSchemaDigest": "365a241c75e446119866ff70ec3799070e582ca6216495733287882da1ddc7a1" + }, + { + "name": "of_work_prepare", + "inputSchemaDigest": "c70372b2ba4156b18192a057397cec78865584a2eb28b8a39090503cc75ea678" + }, + { + "name": "of_work_reconcile", + "inputSchemaDigest": "d746974fa9afd5e951f76f9af38954b0ad7f436f2120dc974da65e5ee39f856f" + }, + { + "name": "of_work_start", + "inputSchemaDigest": "c70372b2ba4156b18192a057397cec78865584a2eb28b8a39090503cc75ea678" + }, + { + "name": "of_work_status", + "inputSchemaDigest": "d746974fa9afd5e951f76f9af38954b0ad7f436f2120dc974da65e5ee39f856f" + }, + { + "name": "of_work_step", + "inputSchemaDigest": "49fea99bd29f2bb55b7d8ed0c359759652128801e8817da7e13ff4fc62de709c" + }, + { + "name": "of_work_verify", + "inputSchemaDigest": "c754761ddfcd760eb1c25f5ac0b1e473c02f542bc86c26865430306db394595a" + }, + { + "name": "organization_bulk_delete", + "inputSchemaDigest": "5e9e84fe099274b2c6ccfcdc24c99a6817dc9bd1816c8903e88754334e7fbf26" + }, + { + "name": "organization_create", + "inputSchemaDigest": "685dfa93e9d7658abce8336e19fadae14432acd581e107ca1071b82bf87f5a38" + }, + { + "name": "organization_delete", + "inputSchemaDigest": "53937233947347e51ccc6a673247378204e9b4b857c202d19a50de7dce903098" + }, + { + "name": "organization_enrichment_availability", + "inputSchemaDigest": "c84f63955ee185ddf02ee034b8e893535542caddb8f123e14377a9b2cb5cb5e7" + }, + { + "name": "organization_enrichment_preview", + "inputSchemaDigest": "0e62ac6f1223833469e32e869d8669737f66c4b79a57e02c72491a44c8764c6c" + }, + { + "name": "organization_search", + "inputSchemaDigest": "f1c0c69c58dc7db2932f086d7c68a19fee48173a593a9400ef9ec4469d853847" + }, + { + "name": "organization_update", + "inputSchemaDigest": "a0f0d70fab73475b19b14705e49d1bd4695f821754a15dbb38cc19c736fecad0" + }, + { + "name": "people_bulk_delete", + "inputSchemaDigest": "ea1f749f52b27b9550195fe7e6638351a8d2a08c9939a93fdfed742e8d50f61f" + }, + { + "name": "people_search", + "inputSchemaDigest": "808fb3bdf4e9d9c76c03d4c7d0cae40ad836510e188ecafc4fc118770c49b7ea" + }, + { + "name": "person_create", + "inputSchemaDigest": "76b22306e862807f6487be40a5a4d39a47e44b7ea4ed9bccfced460f3966f3ba" + }, + { + "name": "person_delete", + "inputSchemaDigest": "90234dec5fa18e284ea4f9ba48d0b41d126a47cfa18037d38fc13d2d5d6969e8" + }, + { + "name": "person_enrichment_apply", + "inputSchemaDigest": "85d6e01945b5d32c3310089e5a9c478cc17869af1a2b1e3603183ccd4603f8fc" + }, + { + "name": "person_enrichment_availability", + "inputSchemaDigest": "57ba1e8b4c2c85c7d5a243d33978855b8e7f52252aa05894ae610a55bfac98c7" + }, + { + "name": "person_enrichment_run", + "inputSchemaDigest": "aa1c287bd7d46637b3ab6c7827f0adc21db5bb1ceac79922ae717f1742d14016" + }, + { + "name": "person_update", + "inputSchemaDigest": "29ce99fe68f7867708e8f8c00e3eacdbc4098465bac905fb4c59532c6087b46f" + }, + { + "name": "project_archive", + "inputSchemaDigest": "8e1d73142851978024b82363756f08c4462df6a24d5dd9e32e1f899f334fb637" + }, + { + "name": "project_create", + "inputSchemaDigest": "341acf01e88b390563d4b0af5b49a0ede5a0a6b10195d324505840a55f339b07" + }, + { + "name": "project_get_context", + "inputSchemaDigest": "af3ecf7989c5616d5706b948aeef6702f6a8e9ea57fbb232b89d8e1283c18dd8" + }, + { + "name": "project_list", + "inputSchemaDigest": "d541410544e7cc7199105a5a42e945e77d7c86829c1c2b3d5e677bbe4245b3aa" + }, + { + "name": "project_planning_get", + "inputSchemaDigest": "af3ecf7989c5616d5706b948aeef6702f6a8e9ea57fbb232b89d8e1283c18dd8" + }, + { + "name": "project_planning_recompute", + "inputSchemaDigest": "af3ecf7989c5616d5706b948aeef6702f6a8e9ea57fbb232b89d8e1283c18dd8" + }, + { + "name": "project_update", + "inputSchemaDigest": "92915951315ab4408370b52e895020d42a434cb9bb9e1f6cc66ee7919083b3c6" + }, + { + "name": "relationship_contact_plan_get", + "inputSchemaDigest": "16e1d1d599620627bac11fbe3b5594516dcdb9347b66191f8ff39835473ef836" + }, + { + "name": "repository_project_link", + "inputSchemaDigest": "bb35830eb96c26a8e3395b962d6e5865075049f5fd4a05329b69f20795d0d28a" + }, + { + "name": "schema_introspect", + "inputSchemaDigest": "172df53f1eea3f3c270adbb5324e9be7548dfe9434e5ed210f9c906fbaa73df4" + }, + { + "name": "task_bulk_delete", + "inputSchemaDigest": "cd792b16a46fc158fc0fd2b45aa5fed054b26ab76c063eb9d329b086eb6d7c25" + }, + { + "name": "task_complete", + "inputSchemaDigest": "c80c13508133f73ba1a3847ff8db86b5fa8c461395460a4b93634db0d193eea7" + }, + { + "name": "task_coupling_edge_create", + "inputSchemaDigest": "43483da1b3257f332593cec25d53b724dc096bcd28c91c1786dcf5328d198102" + }, + { + "name": "task_coupling_edge_delete", + "inputSchemaDigest": "02f1961970b5df495eb8e5b7e49f946bd2bcc6e4d57535f37fcacf3bfe70e2e9" + }, + { + "name": "task_coupling_edge_list", + "inputSchemaDigest": "88e3aa79245faab79d82963319f3157daea249df67562a082f3b4d3c7e8ebbd8" + }, + { + "name": "task_create", + "inputSchemaDigest": "dba851f83828c4a21c454a1bfeda3516fc96f9814ed2cda4c9a8ab7dee14a5b8" + }, + { + "name": "task_delete", + "inputSchemaDigest": "88e3aa79245faab79d82963319f3157daea249df67562a082f3b4d3c7e8ebbd8" + }, + { + "name": "task_get", + "inputSchemaDigest": "88e3aa79245faab79d82963319f3157daea249df67562a082f3b4d3c7e8ebbd8" + }, + { + "name": "task_list", + "inputSchemaDigest": "72bcba941195cc30fcb87860e3be350fd77d973e307e6c0d2610a4b3fd8c8b78" + }, + { + "name": "task_planning_update", + "inputSchemaDigest": "b3d1f9b5c16c5c9439da924ee4e3b4e14bd634cabe0f254c05ce1b348f2ae7c5" + }, + { + "name": "task_update", + "inputSchemaDigest": "ce88173e481fd8268c3575404ed62ae4b2d50077e06032e10fa5536a9cb25de9" + }, + { + "name": "upload_document", + "inputSchemaDigest": "54a9e9047f766adec01083e86b4eee13fec905133f4ce499857204f4a37a7e61" + }, + { + "name": "vault_audit", + "inputSchemaDigest": "3a2699f886b5f0919f0ee3244ddc2e9e968d83b78684f91173de34ce0a2de714" + }, + { + "name": "vault_create", + "inputSchemaDigest": "81e58a5aa75437d3c4499fde1c1437e908d588b771fb313dd66dcac26454800e" + }, + { + "name": "vault_list", + "inputSchemaDigest": "d9a2a69c347bcf02c28b57173a4eb2496352fcd94a34a09397916328e20d6991" + }, + { + "name": "vault_materialization_request", + "inputSchemaDigest": "9b78d43a5873b6391f0b7f60846b314bb33db7c8619b418a8fe174c21599daac" + }, + { + "name": "vault_materialization_status", + "inputSchemaDigest": "f5dfe6e2fb9d33c5962c1bf14c600abd3e6543b9d2b06ab4749eb9e7c53ad56d" + }, + { + "name": "vault_search", + "inputSchemaDigest": "2447d3589729784e85a291e8749ec29798582d59fcc4a335ec470703b2dab65d" + }, + { + "name": "vault_update", + "inputSchemaDigest": "5c4f22c67a26719c6d38d131f34f208de8c45d2c09d70270974bcf9afc22d36f" + }, + { + "name": "work_dependency_policy_get", + "inputSchemaDigest": "273ae93fed8e4802696441ca7537972aeaca876d4347fea49d43758eaea928aa" + }, + { + "name": "work_dependency_policy_update", + "inputSchemaDigest": "ea7bb1d72b9c0cfc1c8416349bba9d9c7b1bd5cc4f3f0672db2d14d8ff3678cf" + }, + { + "name": "work_item_collaboration_cursor_acknowledge", + "inputSchemaDigest": "2830bd1b7d014934ca5f96d325e589b3a62983a6b9aa64c052a193f7eff5e07a" + }, + { + "name": "work_item_collaboration_event_list", + "inputSchemaDigest": "757831934ea1b004d03b2f88ed580c603b1973759cd72909031a3981153d7a9d" + }, + { + "name": "work_item_collaboration_event_send", + "inputSchemaDigest": "6fb7490190182040945261cf9e6936587a5e41a79648088fe0fd041a4d1ab62f" + }, + { + "name": "work_item_collaboration_event_wait", + "inputSchemaDigest": "4cfe44e83d86eceb427501021748e13ce3c86440146ff5ba557388c624ea190c" + }, + { + "name": "work_item_collaboration_participant_add", + "inputSchemaDigest": "775affa4ed4764876e865d8cdf7445b0301c4b400a2858eaa4f88d60b1bcba74" + }, + { + "name": "work_item_collaboration_participant_list", + "inputSchemaDigest": "b1cd3c1c257876482a8378be48fa587cad37e0152827c69a434eb8703adc87e8" + }, + { + "name": "work_item_collaboration_participant_remove", + "inputSchemaDigest": "289a180072f61a3ccced626a892265a88cef75892053eb104407dc4f8b8167a9" + }, + { + "name": "work_item_create", + "inputSchemaDigest": "9432030e671eefe2cd80a6fc710b202fde9612d4f9aefa65f2b263ff5bbb86c8" + }, + { + "name": "work_item_dependencies_replace", + "inputSchemaDigest": "19316286daac22cd767b77043208635b2ac1746c7d7778d86c0bac856cb5b187" + }, + { + "name": "work_item_edit", + "inputSchemaDigest": "983fe084f93c9b2ea3457410045ed71fd6fd7adeb09737cbc927b3a3ff9e1490" + }, + { + "name": "work_item_get", + "inputSchemaDigest": "791a9ca49ae45fc2ef50103461d0970fa1dd38042c661dc637c447e5b2fc8cd0" + }, + { + "name": "work_item_list", + "inputSchemaDigest": "59ab750d6ca5f50cb1e4f6692d56fc33aef00623b889170b4d6e035f018b2bce" + }, + { + "name": "work_item_requeue", + "inputSchemaDigest": "3419ab923c04db8f1a33d98c9dc2cab876ecf6de84c92929545b0ae392a1ef30" + }, + { + "name": "work_item_revision_history", + "inputSchemaDigest": "8002df5e70c1980faf55427dd37a0ef6193e167e19f0d0bba0468b57c2803f88" + }, + { + "name": "work_item_verification_evidence_submit", + "inputSchemaDigest": "b4d34517b8678bab713c8094da5e54fc383878943e654e4349511c53cd74367a" + }, + { + "name": "work_item_verification_history", + "inputSchemaDigest": "791a9ca49ae45fc2ef50103461d0970fa1dd38042c661dc637c447e5b2fc8cd0" + }, + { + "name": "work_item_verification_plan_get", + "inputSchemaDigest": "791a9ca49ae45fc2ef50103461d0970fa1dd38042c661dc637c447e5b2fc8cd0" + } + ] +} diff --git a/plugins/openclaw-openfunctions/README.md b/plugins/openclaw-openfunctions/README.md index 4aec1c1..94f745d 100644 --- a/plugins/openclaw-openfunctions/README.md +++ b/plugins/openclaw-openfunctions/README.md @@ -11,16 +11,15 @@ manifest contract. - Calls `toOpenclawToolPluginTools(registry)` to convert each definition into OpenClaw's current tool-plugin shape. - Ships compiled ESM and uses `openclaw plugins build` to keep - `contracts.tools` synchronized before publishing. + `contracts.tools` synchronized and verifies input schema digests in + `tool-contracts.json`. ## What it does NOT claim - This is a reference / demo, not a production-grade plugin. -- It currently uses a relative import (`../../../src/framework/openclaw.js`) - to the colocated openFunctions framework. It runs from inside the - openFunctions monorepo. To package for standalone publication, either - bundle the framework, depend on a published `openfunction` framework export - (not yet on npm), or inline `toOpenclawTools`. +- It imports the packaged `openfunction/framework/openclaw`, registry and tool + subpaths. The repository development dependency links the built package; + consumers install `openfunction` alongside the plugin. - Marked `"private": true` in `package.json` to prevent accidental publish. ## Where the bridge lives @@ -37,7 +36,7 @@ them callable from openclaw — for example, your team's MCP tools — you write three lines inside an openclaw plugin: ```ts -import { toOpenclawToolPluginTools } from "openfunction/framework"; // when published +import { toOpenclawToolPluginTools } from "openfunction/framework/openclaw"; import { registry } from "./your-tools.js"; const definitions = toOpenclawToolPluginTools(registry); diff --git a/plugins/openclaw-openfunctions/package-lock.json b/plugins/openclaw-openfunctions/package-lock.json index b05f329..b9744a8 100644 --- a/plugins/openclaw-openfunctions/package-lock.json +++ b/plugins/openclaw-openfunctions/package-lock.json @@ -14,10 +14,50 @@ "devDependencies": { "@typescript/native": "npm:typescript@7.0.2", "esbuild": "^0.28.2", - "openclaw": "2026.7.1-2" + "openclaw": "2026.7.1-2", + "openfunction": "file:../.." }, "peerDependencies": { - "openclaw": ">=2026.5.17" + "openclaw": ">=2026.5.17", + "openfunction": "^1.0.0" + } + }, + "../..": { + "name": "openfunction", + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + }, + "bin": { + "openfunction": "dist/src/cli.js" + }, + "devDependencies": { + "@siftable/mcp-server": "1.5.1", + "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", + "@typescript/native": "npm:typescript@7.0.2", + "pg": "8.22.0", + "tsx": "^4.23.12", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@siftable/mcp-server": "^1.5.1", + "pg": "^8.22.0" + }, + "peerDependenciesMeta": { + "@siftable/mcp-server": { + "optional": true + }, + "pg": { + "optional": true + } } }, "node_modules/@esbuild/aix-ppc64": { @@ -4819,6 +4859,10 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/openfunction": { + "resolved": "../..", + "link": true + }, "node_modules/typebox": { "version": "1.3.13", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.13.tgz", diff --git a/plugins/openclaw-openfunctions/package.json b/plugins/openclaw-openfunctions/package.json index fc687a7..bf54e69 100644 --- a/plugins/openclaw-openfunctions/package.json +++ b/plugins/openclaw-openfunctions/package.json @@ -20,24 +20,27 @@ "files": [ "dist/", "openclaw.plugin.json", - "README.md" + "README.md", + "tool-contracts.json" ], "scripts": { "compile": "node scripts/clean.mjs && tsc --noEmit && esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/index.js", - "build": "npm run compile && openclaw plugins build --entry ./dist/index.js", - "build:check": "npm run compile && openclaw plugins build --entry ./dist/index.js --check", - "validate": "npm run compile && openclaw plugins validate --entry ./dist/index.js" + "build": "npm run compile && openclaw plugins build --entry ./dist/index.js && node scripts/check-contracts.mjs --write", + "build:check": "npm run compile && openclaw plugins build --entry ./dist/index.js --check && node scripts/check-contracts.mjs", + "validate": "npm run compile && openclaw plugins validate --entry ./dist/index.js && node scripts/check-contracts.mjs" }, "dependencies": { "typebox": "^1.3.7" }, "peerDependencies": { - "openclaw": ">=2026.5.17" + "openclaw": ">=2026.5.17", + "openfunction": "^1.0.0" }, "devDependencies": { "openclaw": "2026.7.1-2", "esbuild": "^0.28.2", - "@typescript/native": "npm:typescript@7.0.2" + "@typescript/native": "npm:typescript@7.0.2", + "openfunction": "file:../.." }, "openclaw": { "extensions": [ diff --git a/plugins/openclaw-openfunctions/scripts/check-contracts.mjs b/plugins/openclaw-openfunctions/scripts/check-contracts.mjs new file mode 100644 index 0000000..1c7cd00 --- /dev/null +++ b/plugins/openclaw-openfunctions/scripts/check-contracts.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import plugin from '../dist/index.js'; +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + return value; +} +const tools = []; +plugin.register({ config: {}, registerTool(tool) { tools.push(tool); } }); +assert.equal(new Set(tools.map((tool) => tool.name)).size, tools.length); +const actual = { + schemaVersion: 1, + tools: tools.map((tool) => ({ name: tool.name, inputSchemaDigest: createHash('sha256').update(JSON.stringify(canonical(tool.parameters))).digest('hex') })).sort((a, b) => a.name.localeCompare(b.name)), +}; +const manifest = JSON.parse(await readFile(new URL('../openclaw.plugin.json', import.meta.url), 'utf8')); +assert.deepEqual(manifest.contracts.tools.toSorted(), actual.tools.map((tool) => tool.name).toSorted(), 'Manifest names differ from actual SDK registration'); +const snapshotUrl = new URL('../tool-contracts.json', import.meta.url); +if (process.argv.includes('--write')) await writeFile(snapshotUrl, `${JSON.stringify(actual, null, 2)}\n`); +else assert.deepEqual(JSON.parse(await readFile(snapshotUrl, 'utf8')), actual, 'Tool input schema changed; regenerate the reviewed contract snapshot'); +process.stdout.write(`Verified ${tools.length} static plugin names and schema digests.\n`); diff --git a/plugins/openclaw-openfunctions/src/index.ts b/plugins/openclaw-openfunctions/src/index.ts index 92fa5f2..303fad2 100644 --- a/plugins/openclaw-openfunctions/src/index.ts +++ b/plugins/openclaw-openfunctions/src/index.ts @@ -1,8 +1,8 @@ /** Current OpenClaw tool-only plugin reference for openFunctions. */ import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin"; -import type { TSchema } from "typebox"; -import { toOpenclawToolPluginTools } from "../../../src/framework/openclaw.js"; +import { Type } from "typebox"; +import { toOpenclawToolPluginTools } from "openfunction/framework/openclaw"; import { buildSampleRegistry } from "./registry.js"; const definitions = toOpenclawToolPluginTools(buildSampleRegistry()); @@ -18,10 +18,15 @@ export default defineToolPlugin({ name: definition.name, label: definition.label, description: definition.description, - parameters: definition.parameters as TSchema, + parameters: Type.Unsafe(requireSchema(definition.parameters)), ...(definition.optional ? { optional: true } : {}), execute: (params, config, context) => definition.execute(params, config, context), }), ), }); + +function requireSchema(schema: unknown): object { + if (!schema || typeof schema !== "object") throw new Error("Invalid tool schema"); + return schema; +} diff --git a/plugins/openclaw-openfunctions/src/registry.ts b/plugins/openclaw-openfunctions/src/registry.ts index c7b6566..ab5762a 100644 --- a/plugins/openclaw-openfunctions/src/registry.ts +++ b/plugins/openclaw-openfunctions/src/registry.ts @@ -8,8 +8,8 @@ * beyond the bridge function it's showcasing. */ -import { ToolRegistry } from "../../../src/framework/registry.js"; -import { defineTool, err, ok } from "../../../src/framework/tool.js"; +import { ToolRegistry } from "openfunction/framework/registry"; +import { defineTool, err, ok } from "openfunction/framework/tool"; export function buildSampleRegistry(): ToolRegistry { const registry = new ToolRegistry(); diff --git a/plugins/openclaw-openfunctions/tool-contracts.json b/plugins/openclaw-openfunctions/tool-contracts.json new file mode 100644 index 0000000..ee63324 --- /dev/null +++ b/plugins/openclaw-openfunctions/tool-contracts.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "tools": [ + { + "name": "of_calculate", + "inputSchemaDigest": "159c68d0ad6eca20c1b7ef7434621962afeb3e47e839a8e3b2cd2111cd47ad18" + }, + { + "name": "of_convert_units", + "inputSchemaDigest": "3d8c8f59d51151e0dbe2d60060da7d6170d158b0dd0fe1d6075b9854dba0ba3f" + } + ] +} diff --git a/plugins/pi-openfunctions/README.md b/plugins/pi-openfunctions/README.md index 1a96edd..db9cd69 100644 --- a/plugins/pi-openfunctions/README.md +++ b/plugins/pi-openfunctions/README.md @@ -1,7 +1,7 @@ # @openfunctions/pi-openfunctions Reference extension for the `@earendil-works/pi` fork. It converts an -openFunctions `ToolRegistry` with `registerPiTools()` and registers every +openFunctions `ToolRegistry` with `toPiTools()` and registers every selected tool through Pi's `ExtensionAPI.registerTool()` contract. Pi executes TypeScript extensions directly. From this repository: @@ -11,9 +11,9 @@ pi -e ./plugins/pi-openfunctions/src/index.ts ``` For a project-local install, add the extension directory to `.pi/settings.json` -or copy it under `.pi/extensions/`. This package is private because it imports -the colocated framework by relative path; a published extension should depend -on the published framework package instead. +or copy it under `.pi/extensions/`. This private reference imports the packaged `openfunction/framework/pi`, registry +and tool subpaths. Its development dependency links the built package; standalone +consumers install `openfunction` alongside the extension. Tool failures deliberately throw. Current Pi only marks a tool result as an error when `execute()` throws; an `isError` property on a returned object is diff --git a/plugins/pi-openfunctions/package-lock.json b/plugins/pi-openfunctions/package-lock.json index 8d31135..fa669b9 100644 --- a/plugins/pi-openfunctions/package-lock.json +++ b/plugins/pi-openfunctions/package-lock.json @@ -8,12 +8,55 @@ "name": "@openfunctions/pi-openfunctions", "version": "0.1.0", "license": "MIT", + "dependencies": { + "typebox": "^1.3.7" + }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.84.1", - "@typescript/native": "npm:typescript@7.0.2" + "@typescript/native": "npm:typescript@7.0.2", + "openfunction": "file:../.." }, "peerDependencies": { - "@earendil-works/pi-coding-agent": ">=0.84.1" + "@earendil-works/pi-coding-agent": ">=0.84.1", + "openfunction": "^1.0.0" + } + }, + "../..": { + "name": "openfunction", + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + }, + "bin": { + "openfunction": "dist/src/cli.js" + }, + "devDependencies": { + "@siftable/mcp-server": "1.5.1", + "@types/node": "^22.0.0", + "@types/pg": "^8.20.0", + "@typescript/native": "npm:typescript@7.0.2", + "pg": "8.22.0", + "tsx": "^4.23.12", + "typescript": "npm:@typescript/typescript6@6.0.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@siftable/mcp-server": "^1.5.1", + "pg": "^8.22.0" + }, + "peerDependenciesMeta": { + "@siftable/mcp-server": { + "optional": true + }, + "pg": { + "optional": true + } } }, "node_modules/@earendil-works/pi-coding-agent": { @@ -2398,6 +2441,16 @@ "engines": { "node": ">=16.20.0" } + }, + "node_modules/openfunction": { + "resolved": "../..", + "link": true + }, + "node_modules/typebox": { + "version": "1.3.37", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.37.tgz", + "integrity": "sha512-k+Qogwk7lztlItGvFf5v4GZPPStFAFehAyOKYeuqIHzi3dfTFqPOTx38JG09hmpcK6TQgctrTD06U/0CTTpM1g==", + "license": "MIT" } } } diff --git a/plugins/pi-openfunctions/package.json b/plugins/pi-openfunctions/package.json index 7e78725..6d2c4a4 100644 --- a/plugins/pi-openfunctions/package.json +++ b/plugins/pi-openfunctions/package.json @@ -19,15 +19,20 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "@earendil-works/pi-coding-agent": ">=0.84.1" + "@earendil-works/pi-coding-agent": ">=0.84.1", + "openfunction": "^1.0.0" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.84.1", - "@typescript/native": "npm:typescript@7.0.2" + "@typescript/native": "npm:typescript@7.0.2", + "openfunction": "file:../.." }, "pi": { "extensions": [ "./src/index.ts" ] + }, + "dependencies": { + "typebox": "^1.3.7" } } diff --git a/plugins/pi-openfunctions/src/index.ts b/plugins/pi-openfunctions/src/index.ts index 8b0e794..392d0fc 100644 --- a/plugins/pi-openfunctions/src/index.ts +++ b/plugins/pi-openfunctions/src/index.ts @@ -1,21 +1,17 @@ -/** Reference Pi extension for the dependency-free openFunctions bridge. */ - +/** Reference Pi extension consuming the packaged OpenFunction bridge. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { - registerPiTools, - type PiExtensionApiLike, -} from "../../../src/framework/pi.js"; +import { Type } from "typebox"; +import { toPiTools } from "openfunction/framework/pi"; import { buildSampleRegistry } from "./registry.js"; export default function openFunctionsExtension(pi: ExtensionAPI): void { - registerPiTools( - pi as unknown as PiExtensionApiLike, - buildSampleRegistry(), - { - promptSnippet: () => "Calculate values or convert common units", - promptGuidelines: (tool) => [ - `Use ${tool.name} when its calculation or conversion matches the request.`, - ], - }, - ); + const definitions = toPiTools(buildSampleRegistry(), { + promptSnippet: () => "Calculate values or convert common units", + promptGuidelines: (tool) => [`Use ${tool.name} when its calculation or conversion matches the request.`], + }); + for (const definition of definitions) { + if (!definition.parameters || typeof definition.parameters !== "object") throw new Error(`Invalid schema for ${definition.name}`); + // The actual pinned SDK checks content, callbacks and execution context. + pi.registerTool({ ...definition, parameters: Type.Unsafe(definition.parameters) }); + } } diff --git a/plugins/pi-openfunctions/src/registry.ts b/plugins/pi-openfunctions/src/registry.ts index ffa4a6c..f91973e 100644 --- a/plugins/pi-openfunctions/src/registry.ts +++ b/plugins/pi-openfunctions/src/registry.ts @@ -1,5 +1,5 @@ -import { ToolRegistry } from "../../../src/framework/registry.js"; -import { defineTool, err, ok } from "../../../src/framework/tool.js"; +import { ToolRegistry } from "openfunction/framework/registry"; +import { defineTool, err, ok } from "openfunction/framework/tool"; export function buildSampleRegistry(): ToolRegistry { const registry = new ToolRegistry(); diff --git a/scripts/prepare-package.mjs b/scripts/prepare-package.mjs new file mode 100644 index 0000000..de1e4b6 --- /dev/null +++ b/scripts/prepare-package.mjs @@ -0,0 +1,10 @@ +import { chmod, cp, mkdir, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +const root = new URL("../", import.meta.url); +if (process.argv.includes("--clean")) { + await rm(new URL("dist/", root), { recursive: true, force: true }); +} else { + await mkdir(new URL("dist/system-prompts/", root), { recursive: true }); + await cp(new URL("system-prompts/", root), new URL("dist/system-prompts/", root), { recursive: true }); + await chmod(fileURLToPath(new URL("dist/src/cli.js", root)), 0o755); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..088a2bd --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** Executable entry; the public library itself has no startup side effects. */ +import { isAbsolute, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createInterface } from "node:readline"; +import { digestJson } from "./framework/session.js"; +import { readPrivateFile, writePrivateFileAtomic } from "./framework/journal-file.js"; +import type { WorkPreview } from "./framework/work/types.js"; +import type { WorkProfile } from "./runtime/work-profile.js"; + +interface AuthorizationPreview { schemaVersion: 1; configPath: string; configDigest: string; work: WorkPreview; digest: string; } +function flags(args: string[], allowed: string[]): Record { + const result: Record = {}; + for (let index = 0; index < args.length; index += 2) { + const key = args[index]!; const value = args[index + 1]; + if (!allowed.includes(key) || value === undefined || value.startsWith("--") || key in result) throw new Error(`Unexpected or missing option ${key}`); + result[key] = value; + } + return result; +} +function required(options: Record, name: string, absolute = false): string { + const value = options[name]; + if (!value) throw new Error(`Missing ${name}`); + if (absolute && !isAbsolute(value)) throw new Error(`${name} must be an absolute path`); + return value; +} +function emit(value: unknown): void { console.log(JSON.stringify(value, null, 2)); } +async function profile(config: string): Promise { + const { createWorkProfile } = await import("./runtime/work-profile.js"); + return createWorkProfile(config); +} +function readPreview(path: string): AuthorizationPreview { + const preview = JSON.parse(readPrivateFile(path, 4 * 1024 * 1024).toString("utf8")) as AuthorizationPreview; + if (!preview || preview.schemaVersion !== 1 || !preview.work || !preview.configPath || !preview.configDigest || !preview.digest) throw new Error("Invalid authorization preview"); + const { digest, ...rest } = preview; + if (digestJson(rest) !== digest) throw new Error("Authorization preview digest mismatch"); + return preview; +} +async function assertCurrentPreview(runtime: WorkProfile, saved: AuthorizationPreview): Promise { + if (saved.configPath !== runtime.configPath || saved.configDigest !== digestJson(runtime.config)) throw new Error("Configuration changed; prepare a new authorization preview"); + const { readWorkProfileConfig } = await import("./runtime/work-profile.js"); + if (saved.configDigest !== digestJson(readWorkProfileConfig(runtime.configPath))) throw new Error("Configuration changed during review; prepare again"); + const fresh = await runtime.controller.prepare(saved.work.binding.workItemId); + const relevant = (work: WorkPreview) => ({ binding: work.binding, steps: work.steps, contract: work.snapshot.contract, plan: { id: work.plan.id, version: work.plan.version, digest: work.plan.digest, state: work.plan.state, steps: work.plan.steps } }); + if (digestJson(relevant(fresh)) !== digestJson(relevant(saved.work))) throw new Error("Work contract, scope, catalog or verification plan changed; prepare again"); + return fresh; +} +async function authorize(runtime: WorkProfile, saved: AuthorizationPreview): Promise { + const preview = await assertCurrentPreview(runtime, saved); + const expiresAt = Date.now() + runtime.grantTtlMs; + emit({ review: { backend: preview.binding.backendId, workItem: preview.binding.workItemId, binding: preview.binding, roots: preview.snapshot.contract.roots, include: preview.snapshot.contract.include, exclude: preview.snapshot.contract.exclude, actions: preview.snapshot.contract.actions, steps: preview.steps, trustedCatalog: runtime.config.stepCatalog, ...(runtime.config.stepCatalog.kind === "fixture" ? { verificationCommand: { executable: process.execPath, argv: [resolve(runtime.config.workspaceRoot, runtime.config.stepCatalog.verifierPath), resolve(runtime.config.workspaceRoot, runtime.config.stepCatalog.fixturePath)], environment: "local-node", nodeVersion: process.version } } : {}), verification: preview.plan.steps, acceptanceMethods: preview.snapshot.contract.acceptanceCriteria, limits: runtime.limits, expiresAt: new Date(expiresAt).toISOString(), automaticCompletionDefault: false } }); + const readline = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY) }); + const answers = readline[Symbol.asyncIterator](); + async function decision(prompt: string): Promise { + process.stdout.write(prompt); + const result = await answers.next(); + return result.done ? "" : result.value.trim(); + } + try { + if (await decision("Type AUTHORIZE to grant execution for this exact scope (anything else refuses): ") !== "AUTHORIZE") { + emit({ authorized: false }); return; + } + await assertCurrentPreview(runtime, saved); + const common = { expiresAt, actions: preview.snapshot.contract.actions, roots: preview.snapshot.contract.roots, include: preview.snapshot.contract.include, exclude: preview.snapshot.contract.exclude, stepBindings: Object.fromEntries(Object.entries(preview.steps).map(([key, step]) => [key, { toolName: step.toolName, capabilityDigest: step.capabilityDigest }])), acceptanceMethods: preview.snapshot.contract.acceptanceCriteria.map((criterion) => criterion.method), limits: runtime.limits }; + const execution = runtime.grants.issue(preview.binding, { ...common, purpose: "execution" }); + const completionDecision = await decision("Automatic completion requires every displayed method and fresh evidence. Type COMPLETE to grant it; Enter keeps it off: "); + let completionId: string | undefined; + if (completionDecision === "COMPLETE") { + await assertCurrentPreview(runtime, saved); + completionId = runtime.grants.issue(preview.binding, { ...common, purpose: "completion", executionGrantId: execution.id }).id; + } + emit({ authorized: true, executionGrantId: execution.id, ...(completionId ? { completionGrantId: completionId } : {}), automaticCompletion: Boolean(completionId), expiresAt: new Date(expiresAt).toISOString() }); + } finally { readline.close(); } +} +async function workCommand(args: string[]): Promise { + const [operation, ...rest] = args; + if (operation === "prepare") { + const options = flags(rest, ["--config", "--work-item", "--out"]); + const configPath = required(options, "--config", true); const output = required(options, "--out", true); + const runtime = await profile(configPath); + const work = await runtime.controller.prepare(required(options, "--work-item")); + const data = { schemaVersion: 1 as const, configPath, configDigest: digestJson(runtime.config), work }; + writePrivateFileAtomic(output, Buffer.from(JSON.stringify({ ...data, digest: digestJson(data) }, null, 2)), true); + emit({ prepared: true, previewPath: output, workItemId: work.binding.workItemId, contractDigest: work.binding.contractDigest, planDigest: work.binding.planDigest }); + return; + } + if (operation === "authorize") { + const options = flags(rest, ["--config", "--preview"]); + const runtime = await profile(required(options, "--config", true)); + await authorize(runtime, readPreview(required(options, "--preview", true))); + return; + } + if (operation === "grants") { + const [action, ...grantArgs] = rest; + const options = flags(grantArgs, action === "list" ? ["--config"] : ["--config", "--grant"]); + // Grant inspection/revocation never needs a functioning job or step module. + const { readWorkGrantDirectory } = await import("./runtime/work-profile.js"); + const { LocalGrantStore } = await import("./framework/work/grants.js"); + const grantDir = readWorkGrantDirectory(required(options, "--config", true)); + const store = new LocalGrantStore({ rootDir: grantDir }); + if (action === "list") { emit(store.list()); return; } + if (action === "revoke") { const id = required(options, "--grant"); store.revoke(id); emit({ revoked: true, grantId: id }); return; } + } + throw new Error("Usage: openfunction work prepare|authorize|grants list|grants revoke --config ..."); +} +async function main(): Promise { + const [command = "help", ...args] = process.argv.slice(2); + if (["help", "--help", "-h"].includes(command)) { + console.log("Usage: openfunction \n\n" + + " serve --module Serve an exported ToolRegistry over MCP stdio\n" + + " serve --profile work --config Serve bounded local work tools\n" + + " work prepare --config --work-item --out \n" + + " work authorize --config --preview \n" + + " work grants list --config \n" + + " work grants revoke --config --grant \n" + + " hermes-config --config [--command ]\n" + + " workshop Run the bundled workshop tools\n" + + " help Show this help"); + return; + } + if (command === "work") { await workCommand(args); return; } + if (command === "workshop") { + if (args.length) throw new Error("workshop does not accept arguments"); + await import("./workshop.js"); return; + } + if (command === "hermes-config") { + const options = flags(args, ["--config", "--command"]); const runtime = await profile(required(options, "--config", true)); + const { createHermesMcpConfig } = await import("./framework/hermes.js"); + const customCommand = options["--command"]; + emit(createHermesMcpConfig(runtime.registry, { command: customCommand ?? process.execPath, args: [...(customCommand ? [] : [fileURLToPath(import.meta.url)]), "serve", "--profile", "work", "--config", runtime.configPath], cwd: runtime.config.dataDir, trust: "untrusted", protocol: "legacy", resources: false, prompts: false, supportsParallelToolCalls: false })); + return; + } + if (command === "serve") { + const options = flags(args, ["--module", "--profile", "--config"]); + const { startServer, createMcpServer } = await import("./framework/server.js"); + if (options["--profile"] === "work" && !options["--module"]) { + const runtime = await profile(required(options, "--config", true)); + const handle = createMcpServer(runtime.registry, { name: "openfunction-work", version: "1.0.0" }); + let stopping = false; + const stop = () => { + if (stopping) return; stopping = true; + void Promise.resolve(runtime.controller.shutdown()).then(() => handle.close()).then(() => { + process.stdin.pause(); process.exitCode = 0; + }, (error: unknown) => { console.error(error instanceof Error ? error.message : "Work shutdown failed"); process.exitCode = 1; }); + }; + process.once("SIGINT", stop); process.once("SIGTERM", stop); + process.stdin.once("end", stop); + await handle.connect(); return; + } + if (options["--module"] && !options["--profile"] && !options["--config"]) { + const entry = await import(pathToFileURL(resolve(options["--module"])).href); + const { ToolRegistry } = await import("./framework/registry.js"); + const registry = entry.registry ?? entry.default; + if (!(registry instanceof ToolRegistry)) throw new Error("The module must export registry (or default) as a ToolRegistry"); + await startServer(registry, { name: "openfunction", version: "1.0.0" }); return; + } + throw new Error("Usage: openfunction serve --module OR serve --profile work --config "); + } + throw new Error(`Unknown command: ${command}. Run openfunction help.`); +} +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "OpenFunction failed"); + process.exitCode = 1; +}); diff --git a/src/framework/adapters/chat.ts b/src/framework/adapters/chat.ts index 74f8b28..32351ce 100644 --- a/src/framework/adapters/chat.ts +++ b/src/framework/adapters/chat.ts @@ -9,7 +9,8 @@ import * as readline from "node:readline"; import type { AIAdapter, AdapterSessionState, ChatMessage } from "./types.js"; import { validatedAdapterToolCalls } from "./types.js"; import type { ToolRegistry } from "../registry.js"; -import { normalizeToolResult, uncertainToolExecution } from "../tool.js"; +import type { ToolExecutionContext } from "../execution-types.js"; +import { uncertainToolExecution } from "../tool.js"; /** * Start an interactive chat session with the given adapter. @@ -18,6 +19,7 @@ import { normalizeToolResult, uncertainToolExecution } from "../tool.js"; export async function startChat( adapter: AIAdapter, registry: ToolRegistry, + executionContext?: ToolExecutionContext, ): Promise { const rl = readline.createInterface({ input: process.stdin, @@ -57,7 +59,7 @@ export async function startChat( history.push({ role: "user", content: trimmed }); try { - await runConversationTurn(adapter, registry, history); + await runConversationTurn(adapter, registry, history, executionContext); } catch (err) { // Once a tool receipt exists, the turn is part of the durable model // context even when execution certainty is unknown or a later adapter @@ -86,6 +88,7 @@ async function runConversationTurn( adapter: AIAdapter, registry: ToolRegistry, history: ChatMessage[], + executionContext?: ToolExecutionContext, ): Promise { let maxRounds = 10; let sessionState: AdapterSessionState | undefined; @@ -125,12 +128,12 @@ async function runConversationTurn( }); const settledResults = await Promise.allSettled( - calls.map((call) => registry.execute(call.name, call.args)), + calls.map((call) => registry.executeDetailed(call.name, call.args, { ...executionContext, callId: call.id })), ); - const executions = settledResults.map((settled, index) => + const executions = settledResults.map((settled) => settled.status === "rejected" ? uncertainToolExecution(settled.reason) - : normalizeToolResult(calls[index].name, settled.value) + : settled.value ); for (let index = 0; index < calls.length; index += 1) { const call = calls[index]; diff --git a/src/framework/adapters/index.ts b/src/framework/adapters/index.ts index 04131c7..a8bfe99 100644 --- a/src/framework/adapters/index.ts +++ b/src/framework/adapters/index.ts @@ -19,6 +19,8 @@ export type { ChatOptions, ChatMessage, AdapterResponse, + AdapterUsage, + AdapterRequestLimits, ReasoningEffort, ToolCall, } from "./types.js"; diff --git a/src/framework/adapters/types.ts b/src/framework/adapters/types.ts index a4454d6..151cb4b 100644 --- a/src/framework/adapters/types.ts +++ b/src/framework/adapters/types.ts @@ -93,8 +93,23 @@ export interface ChatMessage { providerReplay?: AdapterReplayPayload; } +/** Actual provider-reported token usage; absent measurements stay absent. */ +export interface AdapterUsage { + inputTokens?: number; + outputTokens?: number; + cachedInputTokens?: number; + reasoningTokens?: number; + totalTokens?: number; +} +export interface AdapterRequestLimits { + maxInputTokens?: number; + maxOutputTokens?: number; +} + /** What the adapter returns after calling the AI */ export interface AdapterResponse { + usage?: AdapterUsage; + requestLimits?: AdapterRequestLimits; /** Text response from the AI (if any) */ text?: string; diff --git a/src/framework/agents.ts b/src/framework/agents.ts index 0a7d796..9409a3d 100644 --- a/src/framework/agents.ts +++ b/src/framework/agents.ts @@ -33,17 +33,17 @@ import { randomUUID } from "node:crypto"; import type { ToolResult, ToolDefinition, InputSchema } from "./types.js"; +import type { ToolExecutionContext } from "./execution-types.js"; import type { AIAdapter, AdapterSessionState, ChatMessage, } from "./adapters/types.js"; import { validatedAdapterToolCalls } from "./adapters/types.js"; -import { ToolRegistry } from "./registry.js"; +import type { ToolRegistry } from "./registry.js"; import { composePrompt, autoToolGuide } from "./prompts.js"; import { defineTool, - normalizeToolResult, ok, uncertainToolExecution, } from "./tool.js"; @@ -91,13 +91,21 @@ export interface Agent { ): Promise; } -export interface AgentRunOptions extends RunContext {} +export interface AgentRunOptions extends RunContext { + executionContext?: ToolExecutionContext; +} export interface AgentResult { /** The agent's final text output */ output: string; /** Tool calls made during execution */ - toolCalls: Array<{ name: string; args: Record; result: ToolResult }>; + toolCalls: Array<{ + name: string; + args: Record; + result: ToolResult; + executionReceiptId?: string; + verificationRefs?: readonly string[]; + }>; /** Number of LLM rounds consumed */ rounds: number; /** Immutable record of this execution attempt. */ @@ -245,6 +253,8 @@ function attachAgentToolEffects( name: call.name, args: { ...call.args }, result: { ...call.result }, + ...(call.executionReceiptId === undefined ? {} : { executionReceiptId: call.executionReceiptId }), + ...(call.verificationRefs === undefined ? {} : { verificationRefs: [...call.verificationRefs] }), })); const certainty = receipts.some( (receipt) => receipt.result.executionOutcome === "unknown", @@ -252,6 +262,7 @@ function attachAgentToolEffects( return { ...run, + executionReceiptRefs: toolCalls.flatMap((call) => call.executionReceiptId ? [call.executionReceiptId] : []), toolEffects: { state: "partial", certainty, @@ -282,26 +293,14 @@ export function defineAgent(definition: AgentDefinition): Agent { context?: string, options?: AgentRunOptions, ): Promise { - // Build a filtered registry for this agent - const agentRegistry = new ToolRegistry(); - const allTools = registry.getAll(); - // Treat empty arrays the same as "no filter set" — empty arrays are - // truthy in JS, so a previous `!definition.tools && !definition.toolTags` - // check would skip the all-tools fallback and an agent declared with - // tools: [] silently got zero tools. + // Projections retain the executor, policy, journal and scheduling domain. const hasNameFilter = (definition.tools?.length ?? 0) > 0; const hasTagFilter = (definition.toolTags?.length ?? 0) > 0; - - for (const tool of allTools) { - const byName = hasNameFilter && definition.tools!.includes(tool.name); - const byTag = - hasTagFilter && - definition.toolTags!.some((tag) => tool.tags?.includes(tag)); - - if (byName || byTag || (!hasNameFilter && !hasTagFilter)) { - agentRegistry.register(tool); - } - } + const agentRegistry = registry.project({ + filter: (tool) => (!hasNameFilter && !hasTagFilter) + || (hasNameFilter && definition.tools!.includes(tool.name)) + || (hasTagFilter && definition.toolTags!.some((tag) => tool.tags?.includes(tag))), + }); // Build system prompt const systemPrompt = composePrompt({ @@ -372,25 +371,36 @@ export function defineAgent(definition: AgentDefinition): Agent { // execution to settle. Promise.all would reject early and let // later side effects finish without ever recording their receipts. const settledResults = await Promise.allSettled( - calls.map((call) => agentRegistry.execute(call.name, call.args)), + calls.map((call) => agentRegistry.executeDetailed(call.name, call.args, { + ...options?.executionContext, + callId: call.id, + runId: run.manifest.runId, + correlationId: run.manifest.correlationId, + actor: options?.executionContext?.actor ?? { kind: "agent", id: definition.name }, + authorityGrantRefs: options?.executionContext?.authorityGrantRefs ?? run.manifest.authorityGrantRefs, + authorizationContextId: options?.executionContext?.authorizationContextId ?? run.manifest.authorizationContextId, + })), ); - // Normalize executor rejections and result-serialization failures - // independently. Build every receipt in model call order before + // Keep canonical executor results without serializing them again. + // Normalize rejections and build every receipt in call order before // any model-history operation can fail, so a bad earlier result // never hides a later call's externally visible effect. - const executions = settledResults.map((settled, index) => + const executions = settledResults.map((settled) => settled.status === "rejected" ? uncertainToolExecution(settled.reason) - : normalizeToolResult(calls[index].name, settled.value) + : settled.value ); for (let index = 0; index < calls.length; index += 1) { const call = calls[index]; const execution = executions[index]; + const settled = settledResults[index]; + const receipt = settled.status === "fulfilled" ? settled.value.receipt : undefined; toolCalls.push({ name: call.name, args: call.args, result: execution.result, + ...(receipt ? { executionReceiptId: receipt.executionId, verificationRefs: receipt.verification.evidenceRefs } : {}), }); } for (let index = 0; index < calls.length; index += 1) { @@ -413,6 +423,10 @@ export function defineAgent(definition: AgentDefinition): Agent { const output = response.text ?? ""; run = completeRun(run); + if (toolCalls.length) { + run = { ...run, executionReceiptRefs: toolCalls.flatMap((call) => + call.executionReceiptId ? [call.executionReceiptId] : []) }; + } return { output, toolCalls, @@ -473,10 +487,7 @@ export async function runCrew( // Add delegation tools if enabled. Done once and reused across all modes. let augmentedRegistry = registry; if (delegation && agents.length > 1) { - augmentedRegistry = new ToolRegistry(); - for (const tool of registry.getAll()) { - augmentedRegistry.register(tool); - } + augmentedRegistry = registry.project({}); for (const agent of agents) { augmentedRegistry.register( createDelegationTool(agent, adapter, registry), @@ -732,8 +743,8 @@ function createDelegationTool( }, required: ["task"], }, - handler: async ({ task, context }) => { - const result = await targetAgent.run(task, adapter, registry, context); + handler: async ({ task, context }, executionContext) => { + const result = await registry.withChildExecution(() => targetAgent.run(task, adapter, registry, context, { executionContext })); return ok( { agentName: targetAgent.name, output: result.output, toolCalls: result.toolCalls.length }, `${targetAgent.definition.role} completed the task.`, @@ -1013,7 +1024,7 @@ async function runHierarchicalTaskCrew( // The manager only delegates — give it delegation tools for each worker, // not the workers' raw tools. - const managerRegistry = new ToolRegistry(); + const managerRegistry = registry.project({ names: [] }); for (const worker of workers) { managerRegistry.register(createDelegationTool(worker, adapter, registry)); } diff --git a/src/framework/artifacts.ts b/src/framework/artifacts.ts new file mode 100644 index 0000000..abc09ef --- /dev/null +++ b/src/framework/artifacts.ts @@ -0,0 +1,79 @@ +/** Caller-owned immutable output artifacts; opaque IDs never select filesystem paths. */ +import { createHash, randomUUID } from "node:crypto"; +import { lstatSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { acquireJournalOwnership, initializePrivateRoot, JournalInvariantError, readPrivateFile, writePrivateFileAtomic } from "./journal-file.js"; +import { snapshotJson } from "./session.js"; +export const DEFAULT_ARTIFACT_QUOTA_BYTES = 64 * 1024 * 1024; +export const DEFAULT_MODEL_OUTPUT_BYTES = 32 * 1024; +export interface ArtifactReference { id:string; sha256:string; mediaType:string; sizeBytes:number; } +export interface FileArtifactStoreOptions { rootDir:string; quotaBytes?:number; maxReadBytes?:number; } +export interface ArtifactReadOptions { offset?:number; length?:number; maxBytes?:number; } +interface ArtifactRecord { schemaVersion:1; reference:ArtifactReference; base64:string; } +export class ArtifactQuotaError extends Error {constructor(){super("Artifact quota exhausted");this.name="ArtifactQuotaError";}} +export class FileArtifactStore { + readonly rootDir:string; + readonly quotaBytes:number; + readonly maxReadBytes:number; + private readonly identity:{dev:number;ino:number}; + constructor(options:FileArtifactStoreOptions) { + this.rootDir=initializePrivateRoot(options.rootDir); + this.quotaBytes=options.quotaBytes ?? DEFAULT_ARTIFACT_QUOTA_BYTES; + this.maxReadBytes=options.maxReadBytes ?? DEFAULT_MODEL_OUTPUT_BYTES; + if(!Number.isSafeInteger(this.quotaBytes) || this.quotaBytes < 0 || !Number.isSafeInteger(this.maxReadBytes) || this.maxReadBytes < 0)throw new JournalInvariantError("Artifact limits must be nonnegative safe integers"); + this.identity=lstatSync(this.rootDir); + } + put(content:Uint8Array|string, options:{mediaType?:string} = {}):Readonly { + this.checkRoot(); + const bytes=typeof content === "string" ? Buffer.from(content) : Buffer.from(content); + if(bytes.length > this.quotaBytes)throw new ArtifactQuotaError(); + const mediaType=options.mediaType ?? "application/octet-stream"; + if(typeof mediaType !== "string" || !/^[\w!#$&^.+-]+\/[\w!#$&^.+-]+(?:;[\x20-\x7e]*)?$/.test(mediaType) || mediaType.length > 255)throw new JournalInvariantError("Invalid artifact media type"); + const lock=acquireJournalOwnership(this.rootDir,"artifact-quota"); + try { + let total=0; + for(const name of readdirSync(this.rootDir)) { + if(!name.endsWith(".artifact"))continue; + const id=name.slice(0,-9); this.assertId(id); + total+=this.load(id).reference.sizeBytes; + } + if(total+bytes.length > this.quotaBytes)throw new ArtifactQuotaError(); + const reference:ArtifactReference={id:randomUUID(),sha256:createHash("sha256").update(bytes).digest("hex"),mediaType,sizeBytes:bytes.length}; + const record:ArtifactRecord={schemaVersion:1,reference,base64:bytes.toString("base64")}; + writePrivateFileAtomic(this.path(reference.id),Buffer.from(JSON.stringify(record))); + return snapshotJson(reference); + } finally {lock.release();} + } + metadata(id:string):Readonly {return snapshotJson(this.load(id).reference);} + read(id:string, options:ArtifactReadOptions = {}):Buffer { + const limit=options.maxBytes ?? this.maxReadBytes; + const offset=options.offset ?? 0; + if(!Number.isSafeInteger(limit) || limit < 0 || limit > this.maxReadBytes || !Number.isSafeInteger(offset) || offset < 0)throw new JournalInvariantError("Invalid artifact read bounds"); + const record=this.load(id); + const length=options.length ?? record.reference.sizeBytes-offset; + if(!Number.isSafeInteger(length) || length < 0 || length > limit || offset+length > record.reference.sizeBytes)throw new JournalInvariantError("Artifact read exceeds bounds"); + // Return a fresh copy; changing the consumer buffer cannot change the saved output. + return Buffer.from(Buffer.from(record.base64,"base64").subarray(offset,offset+length)); + } + private assertId(id:string):void {if(typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id))throw new JournalInvariantError("Invalid opaque artifact id");} + private checkRoot():void {initializePrivateRoot(this.rootDir);const current=lstatSync(this.rootDir);if(current.dev !== this.identity.dev || current.ino !== this.identity.ino)throw new JournalInvariantError("Artifact root identity changed");} + private path(id:string):string {this.checkRoot();this.assertId(id);return join(this.rootDir,`${id}.artifact`);} + private load(id:string):ArtifactRecord { + let record:ArtifactRecord; + try {record=JSON.parse(readPrivateFile(this.path(id),Math.ceil(this.quotaBytes/3)*4+4096).toString("utf8")) as ArtifactRecord;} catch(error) {if(error instanceof SyntaxError)throw new JournalInvariantError("Corrupt artifact record");throw error;} + const ref=record?.reference; + if (!record || typeof record !== "object" || Object.keys(record).sort().join(",") !== "base64,reference,schemaVersion" || !ref || Object.keys(ref).sort().join(",") !== "id,mediaType,sha256,sizeBytes") throw new JournalInvariantError("Unexpected artifact metadata fields"); + if(record?.schemaVersion !== 1 || !ref || ref.id !== id || typeof ref.mediaType !== "string" || !Number.isSafeInteger(ref.sizeBytes) || ref.sizeBytes < 0 || ref.sizeBytes > this.quotaBytes || typeof record.base64 !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(record.base64))throw new JournalInvariantError("Invalid artifact metadata"); + const bytes=Buffer.from(record.base64,"base64"); + if(bytes.length !== ref.sizeBytes || createHash("sha256").update(bytes).digest("hex") !== ref.sha256)throw new JournalInvariantError("Artifact size or digest mismatch"); + return record; + } +} + +/** Optional saved-output reader. It never resolves arbitrary paths or reruns a tool. */ +export function createArtifactReadTool(store:FileArtifactStore):import("./types.js").ToolDefinition { + return {name:"of_artifact_read",description:"Read a bounded byte range from an approved immutable output artifact",inputSchema:{type:"object",properties:{artifactId:{type:"string"},offset:{type:"integer",minimum:0},length:{type:"integer",minimum:0,maximum:16384}},required:["artifactId"],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},executionMode:"parallel-safe",replayPolicy:"read_only",contract:{version:"1",useWhen:["Inspect approved saved output"],doNotUseWhen:["Request an arbitrary filesystem path"],sideEffects:[],mutation:false,commitmentClass:"C0",idempotency:"idempotent",reversibility:"read_only"},handler:async params=>{ + const artifact=store.metadata(params.artifactId as string);const offset=(params.offset as number|undefined) ?? 0;const length=(params.length as number|undefined) ?? Math.min(16384,store.maxReadBytes,Math.max(0,artifact.sizeBytes-offset)); + const bytes=store.read(artifact.id,{offset,length});return {success:true,data:{artifact,offset,length,encoding:"base64",data:bytes.toString("base64"),hasMore:offset+length { server.listen(port, host, () => { - console.log(`\n🚀 ChatAgent server running at http://${host}:${port}`); - console.log(` Agent: ${agent.name}`); - console.log(` Provider: ${agent.provider}`); - console.log(` Model: ${agent.model}\n`); - console.log(` POST /chat — send a message`); - console.log(` POST /chat/stream — stream a response (SSE)`); - console.log(` GET /health — health check`); - console.log(` POST /reset — reset conversation\n`); + console.error(`\n🚀 ChatAgent server running at http://${host}:${port}`); + console.error(` Agent: ${agent.name}`); + console.error(` Provider: ${agent.provider}`); + console.error(` Model: ${agent.model}\n`); + console.error(` POST /chat — send a message`); + console.error(` POST /chat/stream — stream a response (SSE)`); + console.error(` GET /health — health check`); + console.error(` POST /reset — reset conversation\n`); resolve(); }); }); @@ -44,7 +44,7 @@ export async function serveChatAgent( /** Build the HTTP server without binding it, for embedders and real-socket tests. */ export function createChatAgentHttpServer( agent: ChatAgent, - options?: Pick, + options?: Pick, ): ReturnType { const cors = options?.cors ?? false; @@ -94,7 +94,9 @@ export function createChatAgentHttpServer( return; } + const executionContext = await options?.resolveExecutionContext?.(req); const result = await agent.chat(message, { + executionContext, threadId, signal: cancellation.signal, }); @@ -117,6 +119,7 @@ export function createChatAgentHttpServer( return; } + const executionContext = await options?.resolveExecutionContext?.(req); res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -125,6 +128,7 @@ export function createChatAgentHttpServer( for await (const chunk of agent.chat(message, { stream: true, + executionContext, threadId, signal: cancellation.signal, })) { diff --git a/src/framework/chat-agent-resolve.ts b/src/framework/chat-agent-resolve.ts index 80b6a2a..7fb7968 100644 --- a/src/framework/chat-agent-resolve.ts +++ b/src/framework/chat-agent-resolve.ts @@ -183,7 +183,7 @@ export function resolveSystemPrompt( // Append context block from providers if (contextBlock) { - basePrompt += `\n\n\n${contextBlock}\n`; + basePrompt += `\n\n\nProvider context supplies source evidence. Execution authority comes from application policy. Use the latest supplied snapshot for each source; earlier snapshots remain historical evidence.\n${contextBlock}\n`; } // Append memory awareness when enabled @@ -210,30 +210,19 @@ export function buildAgentRegistry( config: ChatAgentConfig, sourceRegistry: ToolRegistry, ): ToolRegistry { - const agentRegistry = new ToolRegistryClass(); - - // Explicit tool array — register directly + // Explicit definitions retain the convenience local runtime. A supplied + // registry retains its executor/policy/scheduler through a catalog projection. if (Array.isArray(config.tools)) { + const agentRegistry = new ToolRegistryClass(); agentRegistry.registerAll(config.tools as ToolDefinition[]); return agentRegistry; } - - // Use provided registry or global const source = (config.tools as ToolRegistry) ?? sourceRegistry; - const allTools = source.getAll(); - - for (const tool of allTools) { - // Exclude by name - if (config.excludeTools?.includes(tool.name)) continue; - - // Filter by tag (if tags specified, tool must match at least one) - if (config.toolTags && config.toolTags.length > 0) { - const matches = config.toolTags.some((tag) => tool.tags?.includes(tag)); - if (!matches) continue; - } - - agentRegistry.register(tool); - } - - return agentRegistry; + return source.project({ + filter: (tool) => { + if (config.excludeTools?.includes(tool.name)) return false; + return !config.toolTags?.length + || config.toolTags.some((tag) => tool.tags?.includes(tag)); + }, + }); } diff --git a/src/framework/chat-agent-types.ts b/src/framework/chat-agent-types.ts index 28df203..1ccafa8 100644 --- a/src/framework/chat-agent-types.ts +++ b/src/framework/chat-agent-types.ts @@ -6,9 +6,11 @@ * into a single configurable, embeddable agent. */ +import type { ToolExecutionContext } from "./execution-types.js"; +import type { IncomingMessage } from "node:http"; import type { ToolDefinition } from "./types.js"; import type { ToolRegistry } from "./registry.js"; -import type { ContextProvider } from "./context.js"; +import type { ContextProvider, ContextFreshnessPolicy } from "./context.js"; import type { PromptOptions } from "./prompts.js"; import type { AIAdapter, ChatContent, ChatMessage, ReasoningEffort } from "./adapters/types.js"; import type { Store } from "./store.js"; @@ -73,6 +75,12 @@ export interface ChatSessionConfig { recoverInterrupted?: boolean; } +/** Explicit message/UTF-8 JSON budgets; no token-count approximation. */ +export interface ModelHistoryBudget { + maxMessages?: number; + maxBytes?: number; +} + // ─── Agent Config ────────────────────────────────────────────────────────── export interface ChatAgentConfig { @@ -120,12 +128,21 @@ export interface ChatAgentConfig { /** Memory config. Default: ON (conversation + facts). Set false to disable. */ memory?: boolean | MemoryConfig; + /** Source context refresh policy. Default preserves session behavior. */ + contextFreshness?: ContextFreshnessPolicy; + /** Context providers: string names (e.g., "execufunction") or ContextProvider instances */ providers?: (string | ContextProvider)[]; + /** Optional deterministic model history projection. Default: unbounded. */ + historyBudget?: ModelHistoryBudget; + /** Max tool-calling rounds per turn (default: 10) */ maxToolRounds?: number; + /** Trusted application context for framework-mediated tool admission. */ + executionContext?: ToolExecutionContext; + /** Event-sourced session journal configuration. */ session?: ChatSessionConfig; @@ -212,6 +229,9 @@ export interface ChatAgentChatOptions { /** Override thread ID for this turn */ threadId?: string; + /** Trusted direct-API context; HTTP callers receive context only via the host resolver. */ + executionContext?: ToolExecutionContext; + /** Optional IDs and grants that bind this turn to a larger task graph. */ run?: RunContext; } @@ -241,6 +261,9 @@ export interface ServeOptions { /** Host to bind to (default: "localhost") */ host?: string; + /** Resolve identity/authority from trusted host authentication, never body fields. */ + resolveExecutionContext?: (request: IncomingMessage) => ToolExecutionContext | Promise; + /** Enable CORS headers (default: false) */ cors?: boolean; } diff --git a/src/framework/chat-agent.ts b/src/framework/chat-agent.ts index 5a768f2..351b0c8 100644 --- a/src/framework/chat-agent.ts +++ b/src/framework/chat-agent.ts @@ -31,8 +31,8 @@ import type { } from "./adapters/types.js"; import type { AIAdapter } from "./adapters/types.js"; import type { ToolRegistry } from "./registry.js"; -import type { ConnectedProvider } from "./context.js"; -import { connectProvider, contextPrompt } from "./context.js"; +import type { ConnectedProvider, ContextFreshnessPolicy, ContextSnapshot } from "./context.js"; +import { connectProvider, collectContextSnapshots, formatContextSnapshots, contextSnapshotReferences } from "./context.js"; import { createConversationMemory, createFactMemory, @@ -55,6 +55,7 @@ import type { ChatSessionConfig, ServeOptions, MemoryConfig, + ModelHistoryBudget, } from "./chat-agent-types.js"; import { cancelRun, @@ -67,7 +68,7 @@ import { import type { RunRecord, RunToolEffectReceipt } from "./runs.js"; import { digestJson, SessionKernel } from "./session.js"; import type { SessionEventContext } from "./session.js"; -import { normalizeToolResult } from "./tool.js"; +import type { ToolExecution, ToolExecutionContext, ToolExecutionReceipt, ExecutionJournalSink } from "./execution-types.js"; // ─── Factory ─────────────────────────────────────────────────────────────── @@ -136,6 +137,7 @@ export async function createChatAgent( // create and destroy agents accumulate ghost tools. const providerToolNames: string[] = []; let contextBlock: string | undefined; + let contextSnapshots: ContextSnapshot[] = []; if (config.providers && config.providers.length > 0) { const resolved = await resolveContextProviders(config.providers); @@ -158,8 +160,9 @@ export async function createChatAgent( // Everything after provider connection shares one cleanup boundary. try { - if (connectedProviders.length > 0) { - contextBlock = await contextPrompt(connectedProviders); + if (connectedProviders.length > 0 && !config.raw) { + contextSnapshots = await collectContextSnapshots(connectedProviders, { freshness: config.contextFreshness }); + contextBlock = formatContextSnapshots(contextSnapshots); if (contextBlock === "") contextBlock = undefined; } @@ -187,6 +190,11 @@ export async function createChatAgent( threadIdExplicit, maxToolRounds: config.maxToolRounds ?? 10, session: config.session, + executionContext: config.executionContext, + historyBudget: config.historyBudget, + contextSnapshots, + contextFreshness: config.contextFreshness ?? "session", + sourceContextEnabled: !config.raw, }); } catch (error) { await Promise.allSettled( @@ -211,6 +219,11 @@ interface ChatAgentInternals { threadIdExplicit: boolean; maxToolRounds: number; session?: ChatSessionConfig; + executionContext?: ToolExecutionContext; + historyBudget?: ModelHistoryBudget; + contextSnapshots: ContextSnapshot[]; + contextFreshness: ContextFreshnessPolicy; + sourceContextEnabled: boolean; } class ChatAgentImpl implements ChatAgent { @@ -221,6 +234,11 @@ class ChatAgentImpl implements ChatAgent { private adapter: AIAdapter; private registry: ToolRegistry; private systemPrompt: string; + private executionContext?: ToolExecutionContext; + private historyBudget?: ModelHistoryBudget; + private contextSnapshots: ContextSnapshot[]; + private contextFreshness: ContextFreshnessPolicy; + private sourceContextEnabled: boolean; private conversationMemory?: JournalConversationMemory; private factMemory?: FactMemory; private connectedProviders: ConnectedProvider[]; @@ -237,6 +255,16 @@ class ChatAgentImpl implements ChatAgent { this.adapter = internals.adapter; this.registry = internals.registry; this.systemPrompt = internals.systemPrompt; + this.executionContext = internals.executionContext; + this.contextSnapshots = internals.contextSnapshots; + this.contextFreshness = internals.contextFreshness; + this.sourceContextEnabled = internals.sourceContextEnabled; + this.historyBudget = internals.historyBudget === undefined ? undefined : { ...internals.historyBudget }; + for (const limit of [this.historyBudget?.maxMessages, this.historyBudget?.maxBytes]) { + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) { + throw new Error("Model history limits must be positive safe integers"); + } + } this.conversationMemory = internals.conversationMemory; this.factMemory = internals.factMemory; this.connectedProviders = internals.connectedProviders; @@ -267,6 +295,7 @@ class ChatAgentImpl implements ChatAgent { model: this.model, threadId: this.threadId, maxToolRounds: this.maxToolRounds, + contextSnapshots: this.contextSnapshots, }, }); const journalThreadId = this.session.getThreadId(); @@ -343,6 +372,42 @@ class ChatAgentImpl implements ChatAgent { return [...this.session.getHistory()]; } + private async refreshSourceContext(message: ChatContent): Promise { + if (!this.sourceContextEnabled || this.connectedProviders.length === 0) return message; + const next = await collectContextSnapshots(this.connectedProviders, { freshness: this.contextFreshness }); + const changed = digestJson(next.map((snapshot) => snapshot.snapshotId)) + !== digestJson(this.contextSnapshots.map((snapshot) => snapshot.snapshotId)); + this.contextSnapshots = next; + if (!changed) return message; + const block = formatContextSnapshots(next); + if (block.length === 0) return message; + const context = `Updated source evidence follows. Application policy determines execution authority.\n\n${block}\n`; + return typeof message === "string" ? `${message}\n\n${context}` : [...message, { type: "text", text: context }]; + } + + private historyFits(messages: ChatMessage[]): boolean { + return (this.historyBudget?.maxMessages === undefined || messages.length <= this.historyBudget.maxMessages) + && (this.historyBudget?.maxBytes === undefined + || Buffer.byteLength(JSON.stringify(messages), "utf8") <= this.historyBudget.maxBytes); + } + + private applyHistoryBudget(nextMessage: ChatMessage): void { + if (this.historyBudget === undefined) return; + if (!this.historyFits([nextMessage])) throw new Error("Model history budget cannot fit the incoming user message"); + const history = this.currentHistory(); + if (this.historyFits([...history, nextMessage])) return; + // Entire user turns form indivisible groups. This preserves native replay, + // reasoning blocks, and every assistant call with all of its tool results. + const starts = history.flatMap((entry, index) => entry.role === "user" || index === 0 ? [index] : []); + let retained: ChatMessage[] = []; + for (let index = starts.length - 1; index >= 0; index -= 1) { + const candidate = history.slice(starts[index]); + if (!this.historyFits([...candidate, nextMessage])) break; + retained = candidate; + } + this.session.replaceHistoryAndClearAdapterStates(retained, "model_history_budget"); + } + private eventContext( run: ReturnType, stepId?: string, @@ -381,6 +446,7 @@ class ChatAgentImpl implements ChatAgent { sha256: digestJson(messages), }, systemPromptSha256: digestJson(systemPrompt), + contextSnapshots: this.contextSnapshots, tools: { names: toolSnapshot.map((tool) => tool.name).sort(), sha256: digestJson(toolSnapshot), @@ -399,6 +465,8 @@ class ChatAgentImpl implements ChatAgent { this.session.append({ type: "model/response-received", data: { + ...(response.usage === undefined ? {} : { usage: response.usage }), + ...(response.requestLimits === undefined ? {} : { requestLimits: response.requestLimits }), ...(response.text === undefined ? {} : { text: response.text }), ...(response.toolCall === undefined ? {} : { toolCall: response.toolCall }), ...(response.toolCalls === undefined ? {} : { toolCalls: response.toolCalls }), @@ -481,51 +549,84 @@ class ChatAgentImpl implements ChatAgent { result: RunToolEffectReceipt["result"], context: SessionEventContext, ) => void, - ): Promise<{ - result: Awaited>; - modelContent: string; - }> { + forgetEffect: (call: ToolCall, context: SessionEventContext) => void, + trustedContext: ToolExecutionContext, + ): Promise<{ result: ToolExecution["result"]; modelContent: string }> { this.session.append({ type: "tool/call", data: { call }, ...context }); - this.session.append({ type: "tool/execution-started", data: { call }, ...context }); - rememberEffect(call, { - success: false, - error: "Tool execution started without a durable result receipt", - executionOutcome: "unknown", - }, context); - - let result: Awaited>; + let started = false; + let journalFailure: unknown; + let recorded: { result: ToolExecution["result"]; modelContent: string } | undefined; + const persistResult = (execution: Pick): void => { + if (recorded !== undefined) return; + try { + this.session.append({ + type: "tool/result", + data: { + toolCallId: call.id, + toolName: call.name, + outcome: execution.receipt.executionOutcome, + result: execution.result, + modelContent: execution.modelContent, + receipt: execution.receipt, + ...(execution.result.error === undefined ? {} : { error: execution.result.error }), + }, + ...context, + }); + } catch (error) { + journalFailure = error; + throw error; + } + recorded = { result: execution.result, modelContent: execution.modelContent }; + if (execution.receipt.phase === "not_started" && execution.receipt.admission === "rejected") { + forgetEffect(call, context); + } else if (started) { + rememberEffect(call, { + success: execution.result.success, + ...(execution.result.data === undefined ? {} : { data: execution.result.data }), + ...(execution.result.error === undefined ? {} : { error: execution.result.error }), + executionOutcome: execution.receipt.executionOutcome, + }, context); + } + }; + const lifecycle: ExecutionJournalSink = { + record: (event) => { + if (event.type === "started") { + const receipt = event.data.receipt as ToolExecutionReceipt; + this.session.append({ type: "tool/execution-started", data: { call, receipt }, ...context }); + started = true; + rememberEffect(call, { + success: false, + error: "Tool execution started without a durable result receipt", + executionOutcome: "unknown", + }, context); + } else if (event.type === "settled" || event.type === "rejected") { + if (event.data.receipt !== undefined && event.data.result !== undefined + && typeof event.data.modelContent === "string") { + persistResult(event.data as unknown as ToolExecution); + } + } + }, + }; try { - result = await this.registry.execute(call.name, call.args); + const signals = [trustedContext.signal].filter((signal): signal is AbortSignal => signal !== undefined); + const execution = await this.registry.executeDetailed(call.name, call.args, { + ...trustedContext, + callId: call.id, + runId: context.runId, + correlationId: context.correlationId, + ...(signals.length === 0 ? {} : { signal: signals[0] }), + }, lifecycle); + if (journalFailure !== undefined) throw journalFailure; + if (recorded === undefined) persistResult(execution); + return recorded!; } catch (error) { + if (journalFailure !== undefined) throw error; + if (recorded !== undefined) return recorded; + // Without a durable start, recovery can close the declared call as + // not executed. A started effect must instead retain honest uncertainty. + if (!started) throw error; return this.recordUncertainToolResult(call, error, context, rememberEffect); } - - const execution = normalizeToolResult(call.name, result); - const { result: normalizedResult, modelContent } = execution; - const executionOutcome = execution.outcome; - const normalizedRecord = normalizedResult as unknown as Record; - rememberEffect(call, { - success: normalizedResult.success, - ...(Object.prototype.hasOwnProperty.call(normalizedRecord, "data") - ? { data: normalizedRecord.data } - : {}), - ...(normalizedResult.error === undefined ? {} : { error: normalizedResult.error }), - executionOutcome, - }, context); - - this.session.append({ - type: "tool/result", - data: { - toolCallId: call.id, - toolName: call.name, - outcome: executionOutcome, - result: normalizedResult, - modelContent, - ...(normalizedResult.error === undefined ? {} : { error: normalizedResult.error }), - }, - ...context, - }); - return { result: normalizedResult, modelContent }; } /** @@ -600,6 +701,8 @@ class ChatAgentImpl implements ChatAgent { this.switchThreadIfNeeded(options?.threadId); const currentThreadId = this.threadId; const prompt = options?.systemPrompt ?? this.systemPrompt; + message = await this.refreshSourceContext(message); + this.applyHistoryBudget({ role: "user", content: message }); const projectConversationHistory = (): void => { if (!this.conversationMemory) return; try { @@ -619,6 +722,7 @@ class ChatAgentImpl implements ChatAgent { adapter: this.adapter, instructions: prompt, tools: this.registry.getAll(), + contextSnapshotRefs: contextSnapshotReferences(this.contextSnapshots), maxRounds: this.maxToolRounds, }); const baseContext = this.eventContext(run); @@ -638,6 +742,7 @@ class ChatAgentImpl implements ChatAgent { result: RunToolEffectReceipt["result"], context: SessionEventContext, ): void => { + toolExecutionStarted = true; effectReceipts.set(effectReceiptKey(context.stepId, call.id), { receipt: { name: call.name, @@ -646,6 +751,10 @@ class ChatAgentImpl implements ChatAgent { }, }); }; + const forgetEffect = (call: ToolCall, context: SessionEventContext): void => { + effectReceipts.delete(effectReceiptKey(context.stepId, call.id)); + toolExecutionStarted = effectReceipts.size > 0; + }; const markUndurableEffectsUnknown = (failure: unknown): void => { const durableResultKeys = new Set( this.session.getEvents() @@ -752,6 +861,9 @@ class ChatAgentImpl implements ChatAgent { activeStepId = nextStepId; const requestMessages = this.currentHistory(); + if (!this.historyFits(requestMessages)) { + throw new Error("Model history budget cannot fit the current complete turn; increase the configured budget"); + } const toolSnapshot = this.modelToolSnapshot(); this.recordModelRequest( requestMessages, @@ -814,10 +926,19 @@ class ChatAgentImpl implements ChatAgent { // Calls start in model order and execute concurrently. Result // events record actual completion order; model messages remain in // call order so provider call/result pairing stays deterministic. - toolExecutionStarted = true; journalRecoveryRequired = true; const settledExecutions = await Promise.allSettled( - calls.map((call) => this.executeTool(call, stepContext, rememberEffect)), + calls.map((call) => this.executeTool(call, stepContext, rememberEffect, forgetEffect, { + actor: { kind: "agent", id: this.name }, + authorityGrantRefs: run.manifest.authorityGrantRefs, + ...(run.manifest.authorizationContextId === undefined ? {} : { authorizationContextId: run.manifest.authorizationContextId }), + ...this.executionContext, + ...options?.executionContext, + ...(options?.signal === undefined ? {} : { signal: AbortSignal.any([ + options.signal, + ...[options.executionContext?.signal ?? this.executionContext?.signal].filter((signal): signal is AbortSignal => signal !== undefined), + ]) }), + })), ); const executions = []; for (const outcome of settledExecutions) { @@ -1389,9 +1510,9 @@ class ChatAgentImpl implements ChatAgent { function attachToolEffects( run: RunRecord, toolCalls: ChatResult["toolCalls"], - trackedReceipts: RunToolEffectReceipt[] = [], + trackedReceipts?: RunToolEffectReceipt[], ): RunRecord { - const receipts = trackedReceipts.length > 0 + const receipts = trackedReceipts !== undefined ? trackedReceipts.map((receipt) => ({ name: receipt.name, args: { ...receipt.args }, diff --git a/src/framework/context.ts b/src/framework/context.ts index 519ccb7..37540c7 100644 --- a/src/framework/context.ts +++ b/src/framework/context.ts @@ -38,6 +38,8 @@ * ``` */ +import { randomUUID } from "node:crypto"; +import { digestJson, snapshotJson } from "./session.js"; import type { ToolDefinition } from "./types.js"; import { ToolRegistry } from "./registry.js"; @@ -130,6 +132,12 @@ export interface ConnectedProvider { /** The provider's metadata (same as the parent ContextProvider) */ readonly metadata: ContextProviderMetadata; + /** Non-secret authenticated connection scope used to isolate snapshot caches. */ + readonly contextScope?: ContextScopeIdentity; + + /** Structured source evidence with optional authoritative freshness. */ + buildSnapshot?(): Promise; + /** * Generate tool definitions for this provider's capabilities. * All returned tools are tagged with `"context"` and @@ -159,6 +167,164 @@ export interface ConnectedProvider { disconnect?(): Promise; } +export type ContextFreshnessPolicy = "session" | "turn" | "invalidated"; +export type ContextFreshness = "fresh" | "stale" | "unavailable" | "unknown"; +export interface ContextScopeIdentity { + principalId?: string; + workspaceId?: string; + projectId?: string; +} +export interface ContextSourceReference { + id: string; + uri?: string; + revision?: string; +} +export interface ContextSnapshotItem { + kind: "observation" | "claim" | "inference" | "policy_reference"; + text: string; + sourceRefs?: string[]; +} +/** Provider-owned facts and their known upstream observation information. */ +export interface ContextSnapshotInput { + providerId: string; + scope: ContextScopeIdentity; + content: string; + observedAt?: string; + revision?: string; + expiresAt?: string; + freshness?: ContextFreshness; + completeness?: "complete" | "partial" | "unknown"; + sourceRefs?: ContextSourceReference[]; + items?: ContextSnapshotItem[]; + omissions?: string[]; + warnings?: string[]; +} +/** Immutable captured evidence; retrievedAt is local, observedAt is upstream. */ +export interface ContextSnapshot extends ContextSnapshotInput { + snapshotId: string; + contentDigest: string; + retrievedAt: string; + freshness: ContextFreshness; + completeness: "complete" | "partial" | "unknown"; + sourceRefs: ContextSourceReference[]; + items: ContextSnapshotItem[]; +} +export interface ContextSnapshotReference { + snapshotId: string; + contentDigest: string; + providerId: string; + scope: ContextScopeIdentity; + freshness: ContextFreshness; + completeness: ContextSnapshot["completeness"]; + revision?: string; +} +export interface ContextSnapshotOptions { + freshness?: ContextFreshnessPolicy; + clock?: () => Date; +} + +// Connections are isolated even if two principals reuse the same provider ID. +// Scope changes additionally select a new cache entry inside one connection. +const contextCache = new WeakMap>(); +const invalidatedConnections = new WeakSet(); +function cacheIdentity(provider: ConnectedProvider): string { + return digestJson({ providerId: provider.metadata.id, scope: provider.contextScope ?? {} }); +} +export function invalidateContext(provider: ConnectedProvider): void { + invalidatedConnections.add(provider); +} +function validTime(value: string | undefined): void { + if (value !== undefined && (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value)))) { + throw new Error("Context timestamps must be ISO timestamps"); + } +} +function validateSnapshot(input: ContextSnapshotInput, provider: ConnectedProvider): void { + if (input.providerId !== provider.metadata.id || typeof input.content !== "string" + || input.scope === null || typeof input.scope !== "object" || Array.isArray(input.scope)) { + throw new Error("Invalid context snapshot identity/content"); + } + for (const key of ["principalId", "workspaceId", "projectId"] as const) { + const bound = provider.contextScope?.[key]; + if (bound !== undefined && input.scope[key] !== bound) throw new Error("Context snapshot scope changed"); + if (input.scope[key] !== undefined && (typeof input.scope[key] !== "string" || !input.scope[key])) throw new Error("Invalid context scope identity"); + } + validTime(input.observedAt); + validTime(input.expiresAt); + if (input.revision !== undefined && typeof input.revision !== "string") throw new Error("Invalid context revision"); + if (input.freshness !== undefined && !["fresh", "stale", "unknown", "unavailable"].includes(input.freshness)) throw new Error("Invalid context freshness"); + if (input.completeness !== undefined && !["complete", "partial", "unknown"].includes(input.completeness)) throw new Error("Invalid context completeness"); + for (const source of input.sourceRefs ?? []) { + if (!source || typeof source.id !== "string" || source.id.length === 0) throw new Error("Invalid context source reference"); + } + for (const item of input.items ?? []) { + if (!item || !["observation", "claim", "inference", "policy_reference"].includes(item.kind) || typeof item.text !== "string") throw new Error("Invalid context item"); + } +} +function captured(input: ContextSnapshotInput, retrievedAt: string, freshness?: ContextFreshness): ContextSnapshot { + const state = freshness ?? input.freshness ?? (input.observedAt === undefined ? "unknown" : "fresh"); + const expired = input.expiresAt !== undefined && Date.parse(input.expiresAt) <= Date.parse(retrievedAt); + return snapshotJson({ + ...input, snapshotId: randomUUID(), contentDigest: digestJson({ content: input.content, items: input.items ?? [], sourceRefs: input.sourceRefs ?? [] }), retrievedAt, + freshness: expired && state !== "unavailable" ? "stale" : state, + completeness: input.completeness ?? "unknown", sourceRefs: input.sourceRefs ?? [], items: input.items ?? [], + }, "context snapshot") as ContextSnapshot; +} + +/** Collect evidence without converting text or policy references into grants. */ +export async function collectContextSnapshots(providers: ConnectedProvider[], options: ContextSnapshotOptions = {}): Promise { + const now = (options.clock ?? (() => new Date()))().toISOString(); + const result: ContextSnapshot[] = []; + for (const provider of providers) { + if (!provider.buildSnapshot && !provider.buildContext) continue; + let cache = contextCache.get(provider); + if (cache === undefined) { cache = new Map(); contextCache.set(provider, cache); } + const key = cacheIdentity(provider); + const previous = cache.get(key); + const refresh = previous === undefined || options.freshness === "turn" || invalidatedConnections.has(provider); + if (!refresh && previous !== undefined) { + if (previous.freshness !== "stale" && previous.expiresAt !== undefined && Date.parse(previous.expiresAt) <= Date.parse(now)) { + const expired = captured(previous, now, "stale"); + cache.set(key, expired); + result.push(expired); + } else result.push(previous); + continue; + } + invalidatedConnections.delete(provider); + try { + const input = provider.buildSnapshot !== undefined + ? await provider.buildSnapshot() + : { providerId: provider.metadata.id, scope: provider.contextScope ?? {}, content: await provider.buildContext!() ?? "", freshness: "unknown" as const, completeness: "unknown" as const }; + validateSnapshot(input, provider); + const next = captured(input, now); + cache.set(key, next); + result.push(next); + } catch { + const next = previous === undefined + ? captured({ providerId: provider.metadata.id, scope: provider.contextScope ?? {}, content: "", warnings: ["Provider context is unavailable; retrieval failed."] }, now, "unavailable") + : captured({ ...previous, warnings: [...new Set([...(previous.warnings ?? []), "Provider context refresh failed; retained source evidence is stale."])] }, now, "stale"); + cache.set(key, next); + result.push(next); + } + } + return result; +} +export function contextSnapshotReferences(snapshots: readonly ContextSnapshot[]): ContextSnapshotReference[] { + return snapshots.map(({ snapshotId, contentDigest, providerId, scope, freshness, completeness, revision }) => ({ + snapshotId, contentDigest, providerId, scope, freshness, completeness, ...(revision === undefined ? {} : { revision }), + })); +} +export function formatContextSnapshots(snapshots: readonly ContextSnapshot[]): string { + return snapshots.map((snapshot) => [ + `Source context ${snapshot.providerId}; snapshot ${snapshot.snapshotId}; freshness ${snapshot.freshness}; completeness ${snapshot.completeness}.`, + snapshot.content, + ...(snapshot.content.length === 0 && snapshot.items.length === 0 + ? ["No context text or items were returned in this snapshot. Use this snapshot in place of earlier context from this provider."] : []), + ...(snapshot.items.length === 0 ? [] : snapshot.items.map((item) => `[${item.kind}] ${item.text}`)), + ...(snapshot.omissions ?? []).map((entry) => `Omitted: ${entry}`), + ...(snapshot.warnings ?? []).map((entry) => `Warning: ${entry}`), + ].filter(Boolean).join("\n")).join("\n\n"); +} + // ─── Provider Connection ──────────────────────────────────────────────────── /** @@ -206,7 +372,7 @@ export async function connectProvider( // shadow them. Skip and warn instead of overwrite. registry.registerAll(taggedTools, { overwrite: false }); - console.log( + console.error( `🔗 ${name}: connected (${taggedTools.length} tool${taggedTools.length === 1 ? "" : "s"} registered)`, ); return connected; @@ -243,24 +409,7 @@ export async function connectProvider( export async function contextPrompt( providers: ConnectedProvider[], ): Promise { - const sections: string[] = []; - - for (const provider of providers) { - if (!provider.buildContext) continue; - - try { - const block = await provider.buildContext(); - if (block) { - sections.push(block); - } - } catch (error) { - const name = provider.metadata.name; - const message = error instanceof Error ? error.message : "unknown error"; - console.warn(`⚠️ ${name}: failed to build context — ${message}`); - } - } - - return sections.join("\n\n"); + return formatContextSnapshots(await collectContextSnapshots(providers)); } // ─── Provider Health ──────────────────────────────────────────────────────── diff --git a/src/framework/core.ts b/src/framework/core.ts new file mode 100644 index 0000000..d2d6bb4 --- /dev/null +++ b/src/framework/core.ts @@ -0,0 +1,11 @@ +/** Small, side-effect-free tool-building entry; no host or provider imports. */ +export { defineTool, ok, err } from "./tool.js"; +export { ToolRegistry, registry } from "./registry.js"; +export { createStore } from "./store.js"; +export type { Store, StoreMutation, StoreOptions } from "./store.js"; +export type { + ToolDefinition, ToolResult, ToolExample, ToolTest, InputSchema, + JsonSchemaProperty, CapabilityContract, CommitmentClass, IdempotencyBehavior, + JsonSchema, +} from "./types.js"; +export type * from "./execution-types.js"; diff --git a/src/framework/execution-journal.ts b/src/framework/execution-journal.ts new file mode 100644 index 0000000..92bb5c8 --- /dev/null +++ b/src/framework/execution-journal.ts @@ -0,0 +1,87 @@ +/** Standalone execution evidence. Reopening and replay never dispatch handlers. */ +import { randomUUID } from "node:crypto"; +import { snapshotJson, digestJson } from "./session.js"; +import { JournalInvariantError, SecureJsonlJournal, type JournalEnvelope, type JournalFileLock } from "./journal-file.js"; + +import type { ExecutionJournalEvent } from "./execution-types.js"; +export type { ExecutionJournalEvent } from "./execution-types.js"; +export interface StoredExecutionEvent extends JournalEnvelope, ExecutionJournalEvent { eventId: string; timestamp: string; } +export interface ExecutionJournalOptions { rootDir: string; streamId: string; scopeDigest: string; clock?: () => Date; requireExisting?:boolean; expectedFileIdentity?:{dev:string;ino:string}; } +export interface RecoveredExecution { + executionId: string; + state: "not_started" | "unknown" | "settled"; + events: readonly Readonly[]; + /** Recorded canonical receipt/result/modelContent, returned exactly as evidence. */ + settled?: Readonly>; +} + +/** Authority custody is excluded, not merely hidden from model-facing output. */ +export function assertNoAuthorityCustody(value: unknown): void { + if (value === null || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + if (/^(authorization|credentials?|api[_-]?key|access[_-]?token|refresh[_-]?token|claim[_-]?token|raw[_-]?token|session[_-]?handle|bearer|password|private[_-]?key|grants?|approval)$/i.test(key)) throw new JournalInvariantError(`Authority custody field forbidden in journal: ${key}`); + assertNoAuthorityCustody(child); + } +} + +export class ExecutionJournal { + private readonly file: SecureJsonlJournal; + private readonly options: ExecutionJournalOptions; + constructor(options: ExecutionJournalOptions) { + this.options = {...options}; + this.file = new SecureJsonlJournal({...options, schemaVersion:1, validate:validateExecutionEvent}); + // Verify the entire committed stream immediately; do not defer corruption to dispatch. + this.file.initialize(); + } + fileIdentity():{dev:string;ino:string} {return this.file.fileIdentity();} + acquireOwnership(): JournalFileLock { return this.file.acquireOwnership(); } + read(): readonly Readonly[] { return this.file.read(); } + record(candidate: ExecutionJournalEvent): void { + const event = snapshotJson(candidate, "execution event"); + const previous = this.file.read(); + this.file.append({...event, schemaVersion:1, streamId:this.options.streamId, scopeDigest:this.options.scopeDigest, seq:previous.length+1, eventId:randomUUID(), timestamp:(this.options.clock?.() ?? new Date()).toISOString()},previous.length); + } + getExecution(id: string): RecoveredExecution | undefined { return this.recover().find(item => item.executionId === id); } + recover(): readonly RecoveredExecution[] { + const grouped = new Map[]>(); + for (const event of this.read()) { const items=grouped.get(event.executionId) ?? []; items.push(event); grouped.set(event.executionId,items); } + return Object.freeze([...grouped].map(([executionId, events]) => { + const final=events[events.length-1]!; + const settled=final.type === "settled" || final.type === "rejected" ? final.data : undefined; + return Object.freeze({executionId, state: settled ? "settled" as const : events.some(event=>event.type === "started") ? "unknown" as const : "not_started" as const,events:Object.freeze(events), ...(settled ? {settled} : {})}); + })); + } +} +function validateExecutionEvent(event: Readonly, previous: readonly Readonly[]):void { + if (!event.executionId || !event.eventId || !Number.isFinite(Date.parse(event.timestamp)) || !event.data || Array.isArray(event.data) || typeof event.data !== "object") throw new JournalInvariantError("Malformed execution event"); + assertNoAuthorityCustody(event.data); + if(previous.some(prior=>prior.eventId === event.eventId)) throw new JournalInvariantError("Duplicate execution event id"); + const own=previous.filter(prior=>prior.executionId === event.executionId); + const prior=own[own.length-1]?.type; + const valid = event.type === "requested" ? prior === undefined : event.type === "admitted" || event.type === "rejected" ? prior === "requested" : event.type === "started" ? prior === "admitted" : event.type === "settled" ? prior === "started" || prior === "admitted" : false; + if(!valid)throw new JournalInvariantError(`Invalid execution lifecycle ${prior ?? "none"} -> ${event.type}`); + if(event.type === "settled" || event.type === "rejected") { + if(!event.data.result || typeof event.data.result !== "object" || typeof (event.data.result as {success?:unknown}).success !== "boolean" || typeof event.data.modelContent !== "string")throw new JournalInvariantError("Settlement requires canonical result and model content"); + try{if(digestJson(JSON.parse(event.data.modelContent)) !== digestJson(event.data.result))throw new JournalInvariantError("Model content/result mismatch");}catch{throw new JournalInvariantError("Invalid canonical model content");} + const receipt=event.data.receipt as Record | undefined; + if(!receipt || receipt.executionId !== event.executionId || receipt.schemaVersion !== 1 || !["succeeded","failed","unknown"].includes(String(receipt.executionOutcome)))throw new JournalInvariantError("Receipt execution identity mismatch"); + const requested=own[0]!.data; + for(const key of ["toolName","inputDigest","capabilityDigest"]) { + if(requested[key] !== undefined && receipt[key] !== requested[key])throw new JournalInvariantError(`Receipt ${key} binding mismatch`); + } + if(receipt.resultDigest !== digestJson(event.data.result))throw new JournalInvariantError("Receipt result digest mismatch"); + } + if(event.type === "settled" && prior === "started") { + const receipt=event.data.receipt as {phase?:unknown;admission?:unknown;startedAt?:unknown}; + if(receipt.phase !== "settled" && !(receipt.phase === "not_started" && receipt.admission === "rejected" && receipt.startedAt === undefined))throw new JournalInvariantError("Started settlement requires settled or proven-not-dispatched phase"); + } + // Cancellation between admission and dispatch can settle only as not-started. + if(event.type === "settled" && prior === "admitted") { + const receipt=event.data.receipt as Record | undefined; + if(receipt?.phase !== "not_started") throw new JournalInvariantError("Unstarted settlement requires a not-started receipt"); + } + if(event.type === "rejected") { + const receipt=event.data.receipt as Record | undefined; + if(receipt?.phase !== "not_started" || receipt.admission !== "rejected")throw new JournalInvariantError("Rejection requires a not-started receipt"); + } +} diff --git a/src/framework/execution-types.ts b/src/framework/execution-types.ts new file mode 100644 index 0000000..b26bb46 --- /dev/null +++ b/src/framework/execution-types.ts @@ -0,0 +1,106 @@ +import type { ToolDefinition, ToolResult } from "./types.js"; + +/** Trusted host context. References alone do not confer authority. */ +export interface ToolExecutionContext { + callId?: string; + runId?: string; + correlationId?: string; + actor?: { kind: "local_user" | "agent" | "service" | "host"; id: string }; + scope?: { workspaceId?: string; projectId?: string; workspaceRoot?: string }; + signal?: AbortSignal; + /** Absolute Unix epoch milliseconds. */ + deadline?: number; + authorityGrantRefs?: readonly string[]; + authorizationContextId?: string; + idempotencyKey?: string; + expectedCapabilityDigest?: string; +} + +export interface PolicyDecision { + decision: "allow" | "deny" | "approval_required"; + reasonCodes?: readonly string[]; + /** Independently validated references, never copied from tool arguments. */ + authorityGrantRefs?: readonly string[]; + authorizationContextId?: string; + expiresAt?: number; +} + +export interface ExecutionPolicy { + authorize(input: { + toolSnapshot: ToolDefinition; + params: Readonly>; + context: Readonly; + }): PolicyDecision | Promise; +} + +export type ReplayPolicy = "never" | "read_only" | "provider_keyed"; +export type ExecutionMode = "parallel-safe" | "exclusive"; + +export interface ToolExecutionReceipt { + schemaVersion: 1; + executionId: string; + callId?: string; + runId?: string; + correlationId?: string; + toolName: string; + capabilityDigest?: string; + actor?: ToolExecutionContext["actor"]; + scope?: ToolExecutionContext["scope"]; + admission: "allowed" | "rejected"; + reasonCodes: string[]; + authorityGrantRefs: string[]; + authorizationContextId?: string; + phase: "not_started" | "started" | "settled"; + executionOutcome: "succeeded" | "failed" | "unknown"; + requestedAt: number; + startedAt?: number; + finishedAt?: number; + cancellationObserved: boolean; + deadlineObserved: boolean; + inputDigest?: string; + resultDigest?: string; + idempotency: { disposition: "not_requested" | "unconfirmed" | "provider_confirmed"; keyDigest?: string }; + replayPolicy: ReplayPolicy; + artifactRefs: readonly string[]; + verification: { state: "not_requested" | "pending" | "passed" | "failed" | "inconclusive"; evidenceRefs: readonly string[] }; + errorCategory?: string; + reconciliation?: string; +} + +export interface ToolExecution { + result: ToolResult; + modelContent: string; + outcome: ToolExecutionReceipt["executionOutcome"]; + receipt: ToolExecutionReceipt; +} + +export interface ExecutionJournalEvent { + type: "requested" | "admitted" | "rejected" | "started" | "settled"; + executionId: string; + data: Record; +} +export interface ExecutionJournalSink { + record(event: ExecutionJournalEvent): void | Promise; + /** Trusted final-result observer; runs before reservation release, even if another journal failed. */ + onSettled?(execution: Readonly): void | Promise; +} +export interface ExecutionLogger { + debug?(message: string): void; + warn?(message: string): void; + error?(message: string): void; +} +export interface ToolExecutorOptions { + mode?: "local" | "governed"; + policy?: ExecutionPolicy; + journal?: ExecutionJournalSink; + logger?: ExecutionLogger; + clock?: () => number; + maxParallel?: number; +} +export interface RegistrySelection { + names?: readonly string[]; + tags?: readonly string[]; + capabilities?: readonly string[]; + level?: "read" | "write" | "full"; + filter?: (tool: ToolDefinition) => boolean; +} diff --git a/src/framework/executor.ts b/src/framework/executor.ts new file mode 100644 index 0000000..c7c1453 --- /dev/null +++ b/src/framework/executor.ts @@ -0,0 +1,348 @@ +/** One execution boundary for direct calls, hosts, and bounded work. */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { digestJson, snapshotJson } from "./session.js"; +import { normalizeToolResult, uncertainToolExecution } from "./tool.js"; +import { ToolScheduler, type BoundToolScheduler } from "./tool-scheduler.js"; +import { validateParams, formatValidationErrors } from "./validate.js"; +import type { ToolDefinition, ToolResult } from "./types.js"; +import type { + ExecutionJournalSink, ExecutionMode, ExecutionPolicy, PolicyDecision, + ToolExecution, ToolExecutionContext, ToolExecutionReceipt, ToolExecutorOptions, +} from "./execution-types.js"; + +export type * from "./execution-types.js"; +type ResolveTool = (name: string) => ToolDefinition | undefined; +interface InvocationServices { resolve?: ResolveTool; policies?: readonly ExecutionPolicy[]; lifecycle?: ExecutionJournalSink; catalogDigest?: string; } +interface ActiveInvocation { + context: ToolExecutionContext; + bound: BoundToolScheduler; + childAllowed: boolean; + policies: readonly ExecutionPolicy[]; +} + +/** The digest binds the advertised specification, rather than mutable references. */ +export function capabilityDigest(tool: ToolDefinition): string { + return digestJson({ + name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema ?? null, annotations: tool.annotations ?? null, + metadata: tool._meta ?? null, + tags: tool.tags ?? [], contract: tool.contract ?? null, + replayPolicy: tool.replayPolicy ?? "never", + executionMode: typeof tool.executionMode === "function" + ? Function.prototype.toString.call(tool.executionMode) : tool.executionMode ?? "exclusive", + handler: Function.prototype.toString.call(tool.handler), + }); +} + +function freezeTool(tool: ToolDefinition): ToolDefinition { + return Object.freeze({ ...tool, + inputSchema: snapshotJson(tool.inputSchema) as ToolDefinition["inputSchema"], + ...(tool.outputSchema ? { outputSchema: snapshotJson(tool.outputSchema) as ToolDefinition["inputSchema"] } : {}), + ...(tool.annotations ? { annotations: snapshotJson(tool.annotations) } : {}), + ...(tool._meta ? { _meta: snapshotJson(tool._meta) } : {}), + ...(tool.contract ? { contract: snapshotJson(tool.contract) } : {}), + ...(tool.tags ? { tags: Object.freeze([...tool.tags]) as unknown as string[] } : {}), + }); +} +function copyContext(context: ToolExecutionContext): ToolExecutionContext { + return Object.freeze({ ...context, + ...(context.actor ? { actor: Object.freeze({ ...context.actor }) } : {}), + ...(context.scope ? { scope: Object.freeze({ ...context.scope }) } : {}), + ...(context.authorityGrantRefs ? { authorityGrantRefs: Object.freeze([...context.authorityGrantRefs]) } : {}), + }); +} +function effectiveContext(parent: ToolExecutionContext, child: ToolExecutionContext): ToolExecutionContext { + for (const key of ["actor", "scope"] as const) { + if (child[key] && (parent[key] === undefined || digestJson(child[key]) !== digestJson(parent[key]))) { + throw new Error("Nested execution cannot change actor or scope"); + } + } + if (child.authorityGrantRefs?.some(ref => !parent.authorityGrantRefs?.includes(ref))) { + throw new Error("Nested execution cannot widen authority"); + } + if (child.authorizationContextId && child.authorizationContextId !== parent.authorizationContextId) { + throw new Error("Nested execution cannot change authorization context"); + } + const signals = [parent.signal, child.signal].filter((signal): signal is AbortSignal => signal !== undefined); + const deadlines = [parent.deadline, child.deadline].filter((deadline): deadline is number => deadline !== undefined); + const { callId: _call, expectedCapabilityDigest: _digest, idempotencyKey: _key, ...inherited } = parent; + return copyContext({ ...inherited, ...child, + actor: parent.actor, scope: parent.scope, + authorityGrantRefs: child.authorityGrantRefs ?? parent.authorityGrantRefs, + authorizationContextId: parent.authorizationContextId, + ...(signals.length ? { signal: AbortSignal.any(signals) } : {}), + ...(deadlines.length ? { deadline: Math.min(...deadlines) } : {}), + }); +} + +export class ToolExecutor { + readonly options: Readonly; + private readonly scheduler: ToolScheduler; + private readonly renewalScheduler = new ToolScheduler({ maxParallel: 1 }); + private readonly active = new AsyncLocalStorage(); + private readonly clock: () => number; + constructor(private readonly resolveTool: ResolveTool, options: ToolExecutorOptions = {}) { + if (options.mode === "governed" && !options.policy) throw new Error("Governed execution requires a policy"); + this.options = Object.freeze({ ...options }); + this.clock = options.clock ?? Date.now; + this.scheduler = new ToolScheduler({ maxParallel: options.maxParallel }); + } + + /** Trusted in-process capability; absent from all serializable contexts. */ + withChildExecution(callback: () => Promise): Promise { + const parent = this.active.getStore(); + if (!parent) return Promise.reject(new Error("Child execution requires an active invocation")); + return this.active.run({ ...parent, childAllowed: true }, callback); + } + + /** Bind one adapter-owned renewal definition; ordinary calls cannot select this lane. */ + createRenewalRunner(tool: ToolDefinition, policies: readonly ExecutionPolicy[] = [], lifecycle?: ExecutionJournalSink) { + const frozen = freezeTool(tool); + return (params: Record, context: ToolExecutionContext = {}) => + this.invoke(tool.name, params, context, { resolve: name => name === frozen.name ? frozen : undefined, policies, lifecycle }, true); + } + + executeDetailed(name: string, params: Record, context: ToolExecutionContext = {}, services: InvocationServices = {}): Promise { + return this.invoke(name, params, context, services, false); + } + + private async invoke(name: string, input: Record, supplied: ToolExecutionContext, services: InvocationServices, renewal: boolean): Promise { + const parent = renewal ? undefined : this.active.getStore(); + let context = copyContext(supplied); + let contextError: string | undefined; + if (parent) { + try { + if (!parent.childAllowed) throw new Error("Nested execution requires withChildExecution"); + context = effectiveContext(parent.context, context); + } catch (error) { contextError = error instanceof Error ? error.message : "Invalid child context"; } + } + const policies = [...(this.options.policy ? [this.options.policy] : []), ...(parent?.policies ?? []), ...(services.policies ?? [])]; + const uniquePolicies = [...new Set(policies)]; + const receipt: ToolExecutionReceipt = { + schemaVersion: 1, executionId: randomUUID(), toolName: name, + ...(context.callId ? { callId: context.callId } : {}), + ...(context.runId ? { runId: context.runId } : {}), + ...(context.correlationId ? { correlationId: context.correlationId } : {}), + ...(context.actor ? { actor: context.actor } : {}), ...(context.scope ? { scope: context.scope } : {}), + admission: "rejected", reasonCodes: [], authorityGrantRefs: [], phase: "not_started", + executionOutcome: "failed", requestedAt: this.clock(), cancellationObserved: false, deadlineObserved: false, + idempotency: context.idempotencyKey ? { disposition: "unconfirmed", keyDigest: digestJson(context.idempotencyKey) } : { disposition: "not_requested" }, + replayPolicy: "never", artifactRefs: [], verification: { state: "not_requested", evidenceRefs: [] }, + }; + const sinks = [...new Set([this.options.journal, services.lifecycle].filter((sink): sink is ExecutionJournalSink => sink !== undefined))]; + let journalState: "none" | "requested" | "admitted" | "started" = "none"; + let handlerEntered = false; + const record = async (type: "requested" | "admitted" | "rejected" | "started" | "settled", data: Record) => { + for (const sink of sinks) await sink.record({ type, executionId: receipt.executionId, data }); + if (type === "requested" || type === "admitted" || type === "started") journalState = type; + }; + const finish = async (normalized: ReturnType, persist = true): Promise => { + receipt.finishedAt = this.clock(); + receipt.cancellationObserved ||= !!context.signal?.aborted; + receipt.deadlineObserved = context.deadline !== undefined && this.clock() >= context.deadline; + receipt.executionOutcome = normalized.outcome; + receipt.resultDigest = digestJson(normalized.result); + const snapshot = snapshotJson(receipt) as ToolExecutionReceipt; + if (persist && journalState !== "none") { + try { + await record(journalState === "requested" ? "rejected" : "settled", { receipt: snapshot, result: normalized.result, modelContent: normalized.modelContent }); + } catch { + receipt.errorCategory = "journal_delivery_failed"; + receipt.reconciliation = receipt.startedAt === undefined + ? "Admission was not durably delivered; no handler was invoked." + : "Result persistence failed after dispatch; reconcile effects before any retry."; + if (receipt.startedAt !== undefined) { + receipt.phase = "started"; + normalized = normalizeToolResult(name, { ...normalized.result, success: false, + error: "Result persistence failed after dispatch; execution outcome is unknown", executionOutcome: "unknown" }); + receipt.executionOutcome = "unknown"; + receipt.resultDigest = digestJson(normalized.result); + } + this.options.logger?.error?.(`Execution ${receipt.executionId}: journal delivery failed`); + } + } + const execution = { ...normalized, receipt: snapshotJson(receipt) as ToolExecutionReceipt }; + const observers = sinks.filter(sink => sink.onSettled !== undefined); + const observed = observers.length ? snapshotJson(execution) as ToolExecution : undefined; + const observerErrors: unknown[] = []; + for (const sink of observers) { + try { await sink.onSettled!(observed!); } catch (error) { observerErrors.push(error); } + } + if (observerErrors.length) throw new AggregateError(observerErrors, "Execution result observation failed"); + return execution; + }; + const reject = (reason: string, error = reason === "cancelled_before_start" ? "Tool invocation aborted before dispatch" : reason) => { + receipt.admission = "rejected"; + receipt.reasonCodes = [reason]; + receipt.phase = "not_started"; + return finish(normalizeToolResult(name, { success: false, error, executionOutcome: "failed" })); + }; + const interruption = () => context.signal?.aborted ? "cancelled_before_start" + : context.deadline !== undefined && (!Number.isFinite(context.deadline) || this.clock() >= context.deadline) ? "deadline_before_start" : undefined; + const resolve = services.resolve ?? this.resolveTool; + let tool: ToolDefinition | undefined; + let source: ToolDefinition | undefined; + let params: Record; + try { + if (input === null || typeof input !== "object" || Array.isArray(input)) throw new Error("Tool parameters must be a JSON object"); + params = snapshotJson(input, "tool parameters") as Record; + receipt.inputDigest = digestJson(params); + source = resolve(name); + if (source) { + tool = freezeTool(source); + receipt.capabilityDigest = capabilityDigest(tool); + receipt.replayPolicy = tool.replayPolicy ?? "never"; + if (tool.contract?.requiredVerification?.length) receipt.verification.state = "pending"; + } + await record("requested", { + toolName: name, + ...(receipt.callId ? { callId: receipt.callId } : {}), + ...(receipt.runId ? { runId: receipt.runId } : {}), + ...(receipt.correlationId ? { correlationId: receipt.correlationId } : {}), + ...(receipt.inputDigest ? { inputDigest: receipt.inputDigest } : {}), + ...(receipt.capabilityDigest ? { capabilityDigest: receipt.capabilityDigest } : {}), + }); + } catch { + return reject(journalState === "none" && receipt.inputDigest ? "journal_or_catalog_failed" : "invalid_parameters", "Tool admission failed before dispatch: invalid parameters, catalog, or journal"); + } + if (contextError) return reject("invalid_nested_execution", contextError); + if (!tool) return reject("unknown_tool", `Unknown tool: "${name}"`); + let invalid; + try { invalid = validateParams(params!, tool.inputSchema); } catch { return reject("invalid_schema", "Tool schema could not be validated"); } + if (invalid.length) return reject("invalid_arguments", formatValidationErrors(name, invalid)); + const checkCapability = () => { + const current = resolve(name); + return !current || current.handler !== tool!.handler || capabilityDigest(current) !== receipt.capabilityDigest + || (services.catalogDigest !== undefined && services.catalogDigest !== receipt.capabilityDigest) + || (context.expectedCapabilityDigest !== undefined && context.expectedCapabilityDigest !== receipt.capabilityDigest); + }; + const authorize = async (): Promise => { + let decision: PolicyDecision = { decision: "allow", authorityGrantRefs: [], reasonCodes: ["local_execution"] }; + for (const policy of uniquePolicies) { + const next = await policy.authorize({ toolSnapshot: tool!, params: params!, context }); + if (!next || !["allow", "deny", "approval_required"].includes(next.decision)) return { decision: "deny", reasonCodes: ["invalid_policy_decision"] }; + if (next.decision !== "allow") return next; + // Every constituent authority must still be valid. A later visibility + // policy cannot extend the original grant's expiry or erase its binding. + if (next.expiresAt !== undefined && (!Number.isFinite(next.expiresAt) || next.expiresAt <= this.clock())) return { decision: "deny", reasonCodes: ["expired_authority"] }; + const expiries = [decision.expiresAt, next.expiresAt].filter((expiry): expiry is number => expiry !== undefined); + decision = { ...decision, ...next, + reasonCodes: [...new Set([...(decision.reasonCodes ?? []), ...(next.reasonCodes ?? [])])], + authorityGrantRefs: [...new Set([...(decision.authorityGrantRefs ?? []), ...(next.authorityGrantRefs ?? [])])], + ...(expiries.length ? { expiresAt: Math.min(...expiries) } : {}), + }; + } + if (decision.expiresAt !== undefined && (!Number.isFinite(decision.expiresAt) || decision.expiresAt <= this.clock())) return { decision: "deny", reasonCodes: ["expired_authority"] }; + if (tool!.contract?.requiresAuthority && !(decision.authorityGrantRefs?.length || decision.authorizationContextId)) return { decision: "approval_required", reasonCodes: ["authority_required"] }; + return decision; + }; + try { + const stopped = interruption(); + if (stopped) return reject(stopped); + if (checkCapability()) return reject("stale_capability"); + const initial = await authorize(); + if (initial.decision !== "allow") return reject(initial.decision, initial.reasonCodes?.join(", ") || initial.decision); + let mode: ExecutionMode = "exclusive"; + try { const selected = typeof tool.executionMode === "function" ? tool.executionMode(params!) : tool.executionMode; if (selected === "parallel-safe") mode = selected; } catch { /* Unclassified tools remain exclusive. */ } + const dispatch = async (bound: BoundToolScheduler): Promise => { + const stopped = interruption(); + if (stopped) return reject(stopped); + if (checkCapability()) return reject("stale_capability"); + const decision = await authorize(); + if (decision.decision !== "allow") return reject(decision.decision, decision.reasonCodes?.join(", ") || decision.decision); + receipt.admission = "allowed"; + receipt.reasonCodes = [...(decision.reasonCodes ?? [])]; + receipt.authorityGrantRefs = [...(decision.authorityGrantRefs ?? [])]; + if (decision.authorizationContextId) receipt.authorizationContextId = decision.authorizationContextId; + await record("admitted", { receipt: snapshotJson(receipt) }); + const finalStop = interruption() ?? (decision.expiresAt !== undefined && decision.expiresAt <= this.clock() ? "expired_authority" : undefined); + if (finalStop) return reject(finalStop); + receipt.phase = "started"; + receipt.executionOutcome = "unknown"; + receipt.startedAt = this.clock(); + await record("started", { receipt: snapshotJson(receipt) }); + // Journal I/O is an async boundary. Revocation can arrive while the + // durable intent is being saved, before any handler has entered. + const finalAuthority = await authorize(); + const bindingChanged = digestJson({ refs: decision.authorityGrantRefs ?? [], context: decision.authorizationContextId }) + !== digestJson({ refs: finalAuthority.authorityGrantRefs ?? [], context: finalAuthority.authorizationContextId }); + const afterStartStop = interruption() + ?? (finalAuthority.decision !== "allow" ? finalAuthority.decision : undefined) + ?? (bindingChanged ? "authority_binding_changed" : undefined) + ?? ([decision.expiresAt, finalAuthority.expiresAt].some(expiry => expiry !== undefined && expiry <= this.clock()) ? "expired_authority" : undefined); + if (afterStartStop || checkCapability()) { + receipt.startedAt = undefined; + return reject(afterStartStop ?? "stale_capability"); + } + const controller = new AbortController(); + const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal; + let timer: ReturnType | undefined; + if (context.deadline !== undefined) { + timer = setTimeout(() => controller.abort(new DOMException("Execution deadline exceeded", "TimeoutError")), Math.max(0, Math.min(context.deadline - this.clock(), 2_147_483_647))); + timer.unref?.(); + } + const boundContext = copyContext({ ...context, signal, authorityGrantRefs: receipt.authorityGrantRefs, ...(receipt.authorizationContextId ? { authorizationContextId: receipt.authorizationContextId } : {}) }); + let normalized; + try { + normalized = await this.active.run({ context: boundContext, bound, childAllowed: false, policies: uniquePolicies }, async () => { + handlerEntered = true; + try { return normalizeToolResult(name, await tool!.handler(params!, boundContext)); } + catch (error) { + this.options.logger?.error?.(`Execution ${receipt.executionId}: handler threw; outcome unknown. Input keys: ${Object.keys(params!).sort().join(", ") || "(none)"}`); + return normalizeToolResult(name, { success: false, + error: error instanceof Error ? error.message : "Unknown tool error", + executionOutcome: "unknown" }); + } + }); + } finally { if (timer !== undefined) clearTimeout(timer); } + receipt.cancellationObserved = signal.aborted; + if (normalized.result.content !== undefined || normalized.result.structuredContent !== undefined || normalized.result._meta !== undefined) { + const content = normalized.result.content ?? [{ type: "text" as const, text: normalized.modelContent }]; + const checked = CallToolResultSchema.safeParse({ content, + ...(normalized.result.structuredContent !== undefined ? { structuredContent: normalized.result.structuredContent } : {}), + ...(normalized.result._meta !== undefined ? { _meta: normalized.result._meta } : {}), + }); + if (!checked.success) { + normalized = uncertainToolExecution(new Error("Invalid tool content after dispatch")); + receipt.errorCategory = "invalid_content"; + } + } + if (tool!.outputSchema && normalized.result.success) { + const payload = normalized.result.structuredContent ?? normalized.result.data; + if (payload === null || typeof payload !== "object" || Array.isArray(payload) || validateParams(payload as Record, tool!.outputSchema).length) { + normalized = uncertainToolExecution(new Error("Tool output schema failed after dispatch; reconcile effects")); + receipt.errorCategory = "invalid_output"; + } + } + receipt.phase = "settled"; + if (normalized.outcome === "unknown") receipt.reconciliation = "Independently reconcile the effect before retrying or completing work."; + return finish(normalized); + }; + const scheduler = renewal ? this.renewalScheduler : parent?.bound ?? this.scheduler; + return await scheduler.run(mode, async bound => { + try { return await dispatch(bound); } + catch (error) { + if (!handlerEntered) throw error; + // Observe pipeline uncertainty before releasing the reservation, not + // after the scheduler has already admitted the next queued effect. + receipt.phase = "started"; + receipt.errorCategory = "execution_pipeline_failed"; + receipt.reconciliation = "Execution pipeline failed after entry; reconcile before retrying."; + return finish(uncertainToolExecution(new Error("Execution pipeline failed after durable start")), false); + } + }, { signal: context.signal, deadline: context.deadline, clock: this.clock }); + } catch { + if (handlerEntered) { + receipt.phase = "started"; + receipt.errorCategory = "execution_pipeline_failed"; + receipt.reconciliation = "Execution entered its durable start boundary; reconcile before retrying."; + return finish(uncertainToolExecution(new Error("Execution pipeline failed after durable start")), false); + } + // A failed start append never invokes the handler; leave the prior intent intact. + receipt.startedAt = undefined; + return reject(interruption() ?? "admission_pipeline_failed", "Tool was not dispatched: scheduling, policy, or journal admission failed"); + } + } +} diff --git a/src/framework/hermes.ts b/src/framework/hermes.ts index da92351..a203249 100644 --- a/src/framework/hermes.ts +++ b/src/framework/hermes.ts @@ -17,12 +17,23 @@ export interface HermesMcpStdioServerConfig { command: string; args?: string[]; env?: Record; + cwd?: string; + enabled?: boolean; + protocol?: "auto" | "legacy" | "stateless"; + trust?: "full" | "untrusted"; + lifecycle?: HermesMcpLifecycle; timeout?: number; connect_timeout?: number; supports_parallel_tool_calls?: boolean; tools?: HermesMcpToolFilter; } +/** Native Hermes lifecycle timeouts; zero disables recycling. */ +export interface HermesMcpLifecycle { + idle_timeout_seconds?: number; + max_lifetime_seconds?: number; +} + export interface HermesMcpConfig { mcp_servers: Record; } @@ -37,6 +48,16 @@ export interface CreateHermesMcpConfigOptions args?: string[]; /** Environment passed to the MCP subprocess. Do not hard-code secrets. */ env?: Record; + /** Working directory for the subprocess. Prefer an absolute caller-owned path. */ + cwd?: string; + /** Explicitly enable or park this server in the host configuration. */ + enabled?: boolean; + /** Hermes handshake selection; compiled OpenFunction currently uses legacy MCP. */ + protocol?: "auto" | "legacy" | "stateless"; + /** Untrusted servers require host approval for write-capable tools. */ + trust?: "full" | "untrusted"; + /** Optional subprocess recycling thresholds in seconds. */ + lifecycle?: HermesMcpLifecycle; /** Per-tool timeout in seconds. Hermes currently defaults to 300. */ timeout?: number; /** Initial connection timeout in seconds. Hermes currently defaults to 60. */ @@ -67,6 +88,12 @@ export function createHermesMcpConfig( assertNonEmpty("command", options.command); assertPositive("timeout", options.timeout); assertPositive("connectTimeout", options.connectTimeout); + if (options.cwd !== undefined) assertNonEmpty("cwd", options.cwd); + for (const [key, value] of Object.entries(options.lifecycle ?? {})) { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + throw new Error(`lifecycle.${key} must be a non-negative number`); + } + } const include = selectRuntimeTools(registry, options).map( ({ tool }) => tool.name, @@ -82,6 +109,11 @@ export function createHermesMcpConfig( command: options.command, ...(options.args ? { args: [...options.args] } : {}), ...(options.env ? { env: { ...options.env } } : {}), + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.enabled === undefined ? {} : { enabled: options.enabled }), + ...(options.protocol === undefined ? {} : { protocol: options.protocol }), + ...(options.trust === undefined ? {} : { trust: options.trust }), + ...(options.lifecycle === undefined ? {} : { lifecycle: { ...options.lifecycle } }), ...(options.timeout === undefined ? {} : { timeout: options.timeout }), ...(options.connectTimeout === undefined ? {} diff --git a/src/framework/index.ts b/src/framework/index.ts index b5bd3f8..21346c7 100644 --- a/src/framework/index.ts +++ b/src/framework/index.ts @@ -30,17 +30,27 @@ export { defineTool, ok, err } from "./tool.js"; // Persistent store export { createStore } from "./store.js"; -export type { Store, StoreMutation } from "./store.js"; +export type { Store, StoreMutation, StoreOptions } from "./store.js"; // Postgres store (optional — requires DATABASE_URL) export { createPgStore, closePgPool } from "./pg-store.js"; +export type { PgStore, PgStoreOptions, PgStoreDatabase } from "./pg-store.js"; // Tool registry export { ToolRegistry, registry } from "./registry.js"; +export { ToolExecutor, capabilityDigest } from "./executor.js"; +export type * from "./execution-types.js"; +export { ToolScheduler } from "./tool-scheduler.js"; +export type { BoundToolScheduler, ToolScheduleOptions, ToolSchedulingMode } from "./tool-scheduler.js"; +export { ExecutionJournal } from "./execution-journal.js"; +export type { ExecutionJournalOptions, RecoveredExecution, StoredExecutionEvent } from "./execution-journal.js"; +export { FileArtifactStore, ArtifactQuotaError, createArtifactReadTool, DEFAULT_ARTIFACT_QUOTA_BYTES, DEFAULT_MODEL_OUTPUT_BYTES } from "./artifacts.js"; +export type { ArtifactReference, FileArtifactStoreOptions, ArtifactReadOptions } from "./artifacts.js"; +export * from "./work/index.js"; // MCP server -export { startServer } from "./server.js"; -export type { ServerOptions } from "./server.js"; +export { startServer, createMcpServer } from "./server.js"; +export type { ServerOptions, McpServerHandle, McpRequestContext } from "./server.js"; // System prompts export { composePrompt, autoToolGuide, loadPromptPreset, resolvePrompt, listPresets } from "./prompts.js"; @@ -142,12 +152,21 @@ export type { } from "./runs.js"; // Context Providers -export { connectProvider, contextPrompt, checkProviderHealth } from "./context.js"; +export { connectProvider, contextPrompt, checkProviderHealth, collectContextSnapshots, invalidateContext, contextSnapshotReferences, formatContextSnapshots } from "./context.js"; export type { ContextProvider, ConnectedProvider, ContextProviderMetadata, ContextCapability, + ContextFreshnessPolicy, + ContextFreshness, + ContextScopeIdentity, + ContextSourceReference, + ContextSnapshotItem, + ContextSnapshotInput, + ContextSnapshot, + ContextSnapshotReference, + ContextSnapshotOptions, } from "./context.js"; // Chat Agent @@ -164,6 +183,7 @@ export type { ServeOptions, MemoryConfig, PeerConfig, + ModelHistoryBudget, } from "./chat-agent-types.js"; export type { AdapterSessionState, @@ -171,6 +191,8 @@ export type { ContentPart, TextContentPart, ImageContentPart, + AdapterUsage, + AdapterRequestLimits, } from "./adapters/types.js"; // Event-sourced session kernel @@ -200,6 +222,7 @@ export type { SessionEventOf, SessionEventStore, SessionEventType, + SessionEventSchemaVersion, StepOutcome, ToolResultOutcome, } from "./session.js"; @@ -237,6 +260,7 @@ export type { HermesMcpStdioServerConfig, HermesMcpConfig, CreateHermesMcpConfigOptions, + HermesMcpLifecycle, } from "./hermes.js"; // Types (for students who want TypeScript help) @@ -247,6 +271,7 @@ export type { ToolTest, InputSchema, JsonSchemaProperty, + JsonSchema, GeminiFunctionDeclaration, AnthropicTool, OpenAIFunction, diff --git a/src/framework/journal-file.ts b/src/framework/journal-file.ts new file mode 100644 index 0000000..03459bb --- /dev/null +++ b/src/framework/journal-file.ts @@ -0,0 +1,404 @@ +/** Private, synchronous JSONL durability shared by execution and work journals. + * Newline is the commit marker: only a final uncommitted fragment is discarded. + * fsync protects acknowledged file contents and directory entries on supported + * POSIX filesystems. Kill tests establish process-crash recovery, not guarantees + * about storage hardware or filesystem behavior after power loss. Windows does + * not expose directory fsync through this implementation. + */ +import { createHash, randomUUID } from "node:crypto"; +import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeSync } from "node:fs"; +import type { Stats } from "node:fs"; +import { dirname, join, resolve, parse } from "node:path"; +import { TextDecoder } from "node:util"; +import { snapshotJson } from "./session.js"; + +export class JournalInvariantError extends Error { constructor(message: string) { super(message); this.name = "JournalInvariantError"; } } +export class JournalConcurrencyError extends Error { constructor(id: string) { super(`Journal ${id} is owned or was concurrently modified`); this.name = "JournalConcurrencyError"; } } +export interface JournalEnvelope { schemaVersion: number; streamId: string; scopeDigest: string; seq: number; } +export interface SecureJournalOptions { + rootDir: string; streamId: string; scopeDigest: string; schemaVersion: number; requireExisting?:boolean; expectedFileIdentity?:{dev:string;ino:string}; + validate?: (event: Readonly, previous: readonly Readonly[]) => void; +} +const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW; + +export class SecureJsonlJournal { + readonly rootDir: string; + private readonly options: SecureJournalOptions; + private readonly rootIdentity: {dev: number; ino: number}; + private requiredFile:boolean; + constructor(options: SecureJournalOptions) { + if (!options.streamId || !options.scopeDigest || !Number.isSafeInteger(options.schemaVersion) || options.schemaVersion < 1) throw new JournalInvariantError("Invalid journal identity/version"); + this.options = {...options};this.requiredFile=options.requireExisting ?? false; + // resolve removes a trailing slash before lstat; otherwise POSIX can follow a final link. + this.rootDir = initializePrivateRoot(options.rootDir); + this.rootIdentity = lstatSync(this.rootDir); + } + private checkRoot(): void { + assertNoSymlinkComponents(this.rootDir); + const current = lstatSync(this.rootDir); + assertPrivateDirectory(current, this.rootDir); + if (current.dev !== this.rootIdentity.dev || current.ino !== this.rootIdentity.ino) throw new JournalInvariantError("Journal root identity changed"); + } + private path(suffix: string): string { + this.checkRoot(); + const digest = createHash("sha256").update(this.options.streamId).digest("hex"); + return join(this.rootDir, `${digest}.${suffix}`); + } + /** Persist an empty stream before another journal can refer to execution intent. */ + initialize():void { + const lock=acquireExclusiveLock(this.path("lock"),this.options.streamId); + try{const path=this.path("jsonl");if(!lstatIfExists(path)){ + if(this.requiredFile)throw new JournalInvariantError("Required journal disappeared"); + writePrivateFileAtomic(path,Buffer.alloc(0)); + } + this.readLog();this.requiredFile=true; + if(!this.options.expectedFileIdentity){const stats=lstatSync(path,{bigint:true});this.options.expectedFileIdentity={dev:String(stats.dev),ino:String(stats.ino)};} + }finally{lock.release();} + } + fileIdentity():{dev:string;ino:string} { + this.readLog();const stats=lstatSync(this.path("jsonl"),{bigint:true});return {dev:String(stats.dev),ino:String(stats.ino)}; + } + acquireOwnership(): JournalFileLock { return acquireExclusiveLock(this.path("owner"), this.options.streamId); } + read(): readonly Readonly[] { return this.readLog().events; } + append(candidate: T, expectedSeq: number): Readonly { + const event = snapshotJson(candidate, "journal event"); + const lock = acquireExclusiveLock(this.path("lock"), this.options.streamId); + try { + const log = this.readLog(); + if (expectedSeq !== log.events.length) throw new JournalConcurrencyError(this.options.streamId); + this.validate(event, log.events); + const path = this.path("jsonl"); + const existing = lstatIfExists(path); + const fd = openSync(path, constants.O_RDWR | constants.O_APPEND | noFollow | (existing ? 0 : constants.O_CREAT | constants.O_EXCL), 0o600); + try { + if (!existing) fchmodSync(fd, 0o600); + assertPrivateFile(fstatSync(fd), path); + if (existing && (fstatSync(fd).ino !== existing.ino || fstatSync(fd).dev !== existing.dev)) throw new JournalConcurrencyError(this.options.streamId); + if (log.torn) ftruncateSync(fd, log.committed); + writeAll(fd, Buffer.from(`${JSON.stringify(event)}\n`)); + fsyncSync(fd); + } finally { closeSync(fd); } + if (!existing) fsyncDirectory(this.rootDir); + return event; + } finally { lock.release(); } + } + private validate(event: Readonly, previous: readonly Readonly[]): void { + if (event === null || typeof event !== "object" || event.schemaVersion !== this.options.schemaVersion || event.streamId !== this.options.streamId || event.scopeDigest !== this.options.scopeDigest || event.seq !== previous.length + 1) throw new JournalInvariantError("Journal version, identity, scope or sequence mismatch"); + this.options.validate?.(event, previous); + } + private readLog(): {events: readonly Readonly[]; committed: number; torn: boolean} { + const path = this.path("jsonl"); + let bytes: Buffer; + try { + if(this.options.expectedFileIdentity){const stat=lstatSync(path,{bigint:true});if(String(stat.dev) !== this.options.expectedFileIdentity.dev || String(stat.ino) !== this.options.expectedFileIdentity.ino)throw new JournalInvariantError("Required journal file identity changed");} + bytes = readPrivateFile(path); + } catch(error) { if (isNodeError(error) && error.code === "ENOENT") {if(!this.requiredFile)bytes = Buffer.alloc(0);else throw new JournalInvariantError("Required journal disappeared");} else throw error; } + const committed = bytes.lastIndexOf(10) + 1; + let decoded: string; + try { decoded = new TextDecoder("utf-8", {fatal:true}).decode(bytes.subarray(0, committed)); } catch { throw new JournalInvariantError("Committed journal bytes are not UTF-8"); } + const events: Readonly[] = []; + for (const line of committed ? decoded.slice(0,-1).split("\n") : []) { + let event: Readonly; + try { event = snapshotJson(JSON.parse(line) as T); } catch { throw new JournalInvariantError(`Corrupt committed journal line ${events.length + 1}`); } + this.validate(event, events); events.push(event); + } + return {events: Object.freeze(events), committed, torn: committed < bytes.length}; + } +} + +/** Existing caller-owned permissions are never silently widened or repaired. */ +export function initializePrivateRoot(value: string): string { + if (typeof value !== "string" || !value.trim()) throw new JournalInvariantError("Private root must be nonempty"); + const root = resolve(value); + assertNoSymlinkComponents(root); + const existing = lstatIfExists(root); + if (existing) { assertPrivateDirectory(existing, root); return root; } + const firstCreated = mkdirSync(root, {recursive:true, mode:0o700}); + assertNoSymlinkComponents(root); + assertPrivateDirectory(lstatSync(root), root); + fsyncDirectory(root); + if (firstCreated) { + let current = root; + for (;;) { const parent = dirname(current); fsyncDirectory(parent); if(current === resolve(firstCreated)) break; if(parent === current) throw new JournalInvariantError("Invalid directory creation chain"); current = parent; } + } + return root; +} +function assertNoSymlinkComponents(path: string): void { + const absolute = resolve(path); let current = parse(absolute).root; + for (const component of absolute.slice(current.length).split(/[\\/]/).filter(Boolean)) { + current = join(current, component); const stats = lstatIfExists(current); + if (!stats) break; + if (stats.isSymbolicLink() || !stats.isDirectory()) throw new JournalInvariantError(`Private path has non-directory or symlink component: ${current}`); + } +} +function assertPrivateDirectory(stats: Stats, path: string): void { + if (!stats.isDirectory() || stats.isSymbolicLink() || (stats.mode & 0o7777) !== 0o700 || (typeof process.getuid === "function" && stats.uid !== process.getuid())) throw new JournalInvariantError(`Private root must be owned real mode 0700 directory: ${path}`); +} +function assertPrivateFile(stats: Stats, path: string): void { + if (!stats.isFile() || stats.isSymbolicLink() || (stats.mode & 0o7777) !== 0o600 || (typeof process.getuid === "function" && stats.uid !== process.getuid())) throw new JournalInvariantError(`Private file must be owned regular mode 0600 file: ${path}`); +} +export function readPrivateFile(path: string, maximumBytes = Number.MAX_SAFE_INTEGER): Buffer { + const before = lstatSync(path); assertPrivateFile(before, path); + const fd = openSync(path, constants.O_RDONLY | noFollow); + try { + const opened = fstatSync(fd); assertPrivateFile(opened, path); + if (opened.dev !== before.dev || opened.ino !== before.ino) throw new JournalInvariantError("Private file identity changed"); + if (opened.size > maximumBytes) throw new JournalInvariantError("Private file exceeds read limit"); + const result = Buffer.alloc(opened.size); + let offset = 0; + while(offset < result.length) { + const count = readSync(fd, result, offset, result.length-offset, offset); + if(count === 0) throw new JournalConcurrencyError(path); + offset += count; + } + const after = fstatSync(fd); + if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw new JournalConcurrencyError(path); + if (result.length > maximumBytes) throw new JournalInvariantError("Private file exceeds read limit"); + return result; + } finally { closeSync(fd); } +} +/** Atomic create never replaces an existing immutable file. */ +export function writePrivateFileAtomic(path: string, bytes: Buffer, replace = false): void { + const parent = initializePrivateRoot(dirname(path)); + const temporary = join(parent, `.${randomUUID()}.tmp`); + const fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow, 0o600); + try { fchmodSync(fd,0o600); writeAll(fd, bytes); fsyncSync(fd); } finally {closeSync(fd);} + try { + if(replace) { const old = lstatIfExists(path); if(old) assertPrivateFile(old,path); renameSync(temporary,path); } + else { linkSync(temporary,path); unlinkSync(temporary); } + fsyncDirectory(parent); + } finally { if(unlinkIfExists(temporary)) fsyncDirectory(parent); } +} +export function acquireJournalOwnership(rootDir: string, identity: string): JournalFileLock { + const root = initializePrivateRoot(rootDir); + return acquireExclusiveLock(join(root, `${createHash("sha256").update(identity).digest("hex")}.owner`),identity); +} +function lstatIfExists(path: string): Stats | undefined { try {return lstatSync(path);} catch(error) {if(isNodeError(error) && error.code === "ENOENT") return undefined; throw error;} } +function writeAll(fd:number,bytes:Buffer):void {let offset=0; while(offset(); + let reclaimPath = reclaimRoot; + + for (;;) { + try { + linkSync(claimPath, reclaimPath); + fsyncDirectory(lockDirectory); + break; + } catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") { + try { + if (sameFile(reclaimPath, claimPath)) unlinkIfExists(reclaimPath); + } catch { + // Preserve the acquisition failure below. + } + throw error; + } + + const owner = readLockOwner(reclaimPath); + if (owner === undefined || isProcessAlive(owner.pid)) return undefined; + if (observedGateTokens.has(owner.token)) { + throw new JournalInvariantError( + `session ${sessionId} has an inconsistent stale-lock recovery chain`, + ); + } + observedGateTokens.add(owner.token); + + // Never unlink a crashed reclaimer's gate. Its unique token names the + // next generation, so exactly one contender can advance the chain and + // old generations can never be confused with a replacement main lock. + reclaimPath = `${reclaimRoot}.${owner.token}`; + } + } + + let released = false; + return { + release(): void { + if (released) return; + released = true; + if (sameFile(reclaimPath, claimPath)) unlinkIfExists(reclaimPath); + fsyncDirectory(lockDirectory); + }, + }; +} + +function readLockOwner(path: string): JournalLockOwner | undefined { + try { + const parsed = JSON.parse(readPrivateFile(path,512).toString("utf8")) as Partial; + if (!Number.isSafeInteger(parsed.pid) || (parsed.pid ?? 0) <= 0) return undefined; + if (typeof parsed.token !== "string" || !/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(parsed.token)) return undefined; + return { pid: parsed.pid!, token: parsed.token }; + } catch { return undefined; } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ESRCH") return false; + if (isNodeError(error) && error.code === "EPERM") return true; + throw error; + } +} + +function sameFile(left: string, right: string): boolean { + try { + const leftStat = lstatSync(left); + const rightStat = lstatSync(right); + return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return false; + throw error; + } +} + +function unlinkIfExists(path: string): boolean { + try { + unlinkSync(path); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return false; + throw error; + } +} diff --git a/src/framework/openclaw.ts b/src/framework/openclaw.ts index 0b978ee..e56dcc7 100644 --- a/src/framework/openclaw.ts +++ b/src/framework/openclaw.ts @@ -31,25 +31,26 @@ import type { ToolRegistry } from "./registry.js"; import type { ToolDefinition, ToolResult } from "./types.js"; +import type { ToolExecutionReceipt } from "./execution-types.js"; import { - executeRuntimeTool, - formatRuntimeToolResult, + executeRuntimeToolDetailed, + runtimeToolContent, + runtimeToolFailureMessage, selectRuntimeTools, + type NativeToolContent, type RuntimeToolSelectionOptions, } from "./runtime-tool-bridge.js"; // ─── Output shape — compatible with openclaw's AnyAgentTool ──────────────── /** A single content block returned to openclaw. Matches the SDK shape. */ -export interface OpenclawToolContentBlock { - type: "text"; - text: string; -} +export type OpenclawToolContentBlock = NativeToolContent; export interface OpenclawToolResult { type: "text" | "error"; content: OpenclawToolContentBlock[]; isError?: boolean; + details?: { executionReceipt: ToolExecutionReceipt; result: ToolResult }; } /** @@ -159,7 +160,6 @@ export function toolToOpenclaw( ): OpenclawToolShape { const namePrefix = options.namePrefix ?? ""; const exposedName = `${namePrefix}${tool.name}`; - const formatResult = options.formatResult ?? defaultFormatResult; const label = options.label ? options.label(tool) : tool.name; return { @@ -167,11 +167,12 @@ export function toolToOpenclaw( label, description: tool.description, parameters: tool.inputSchema, - async execute(_toolCallId, params, _signal) { - // The bridge checks cancellation before and after execution. The - // framework handler contract itself does not yet accept a signal. - const result = await executeRuntimeTool(registry, tool, params, _signal); - return formatResult(result, tool); + async execute(toolCallId, params, signal) { + const context = await options.resolveExecutionContext?.({ tool, toolCallId, signal }); + const { result, receipt } = await executeRuntimeToolDetailed(registry, tool, params, signal, { callId: toolCallId, ...context }); + return options.formatResult + ? options.formatResult(result, tool) + : { ...defaultFormatResult(result, tool), details: { executionReceipt: receipt, result } }; }, }; } @@ -187,23 +188,10 @@ function defaultFormatResult( result: ToolResult, _tool: ToolDefinition, ): OpenclawToolResult { - if (!result.success) { - return { - type: "error", - isError: true, - content: [ - { - type: "text", - text: `Error: ${result.error ?? "unknown error"}`, - }, - ], - }; - } - - const text = formatRuntimeToolResult(result); return { - type: "text", - content: [{ type: "text", text }], + type: result.success ? "text" : "error", + ...(result.success ? {} : { isError: true }), + content: runtimeToolContent(result), }; } @@ -226,18 +214,26 @@ export function toOpenclawToolPluginTools( parameters: tool.inputSchema, ...(options.optional?.(tool) ? { optional: true } : {}), async execute(params, _config, context) { - const result = await executeRuntimeTool( + const executionContext = await options.resolveExecutionContext?.({ + tool, toolCallId: context.toolCallId, signal: context.signal, hostContext: context, + }); + const { result, receipt } = await executeRuntimeToolDetailed( registry, tool, params, context.signal, + { callId: context.toolCallId, ...executionContext }, ); if (!result.success) { - throw new Error(result.error ?? "unknown error"); + const error = new Error(runtimeToolFailureMessage(result)); + Object.assign(error, { executionReceipt: receipt, result }); + throw error; } return options.formatResult ? options.formatResult(result, tool) - : defaultToolPluginResult(result); + : result.content !== undefined + ? { content: runtimeToolContent(result), details: { executionReceipt: receipt, result } } + : defaultToolPluginResult(result); }, }), ); diff --git a/src/framework/pg-store.ts b/src/framework/pg-store.ts index 4441fcb..4bc6070 100644 --- a/src/framework/pg-store.ts +++ b/src/framework/pg-store.ts @@ -1,150 +1,146 @@ -/** - * OpenFunction — Postgres Store - * - * Same API as createStore() but backed by Postgres instead of JSON files. - * Data loads into memory on startup, writes through to Postgres on every change. - * Switching from JSON to Postgres is a one-line change: - * - * // Before (JSON files): - * const tasks = createStore("tasks"); - * - * // After (Postgres): - * const tasks = await createPgStore("tasks"); - * - * All your tool code stays exactly the same — get(), set(), getAll(), etc. - * - * Setup: - * 1. Have Postgres running (local Docker, Cloud SQL, Supabase, Neon, etc.) - * 2. Set DATABASE_URL: export DATABASE_URL=postgres://user:pass@localhost:5432/mydb - * 3. That's it — tables are created automatically - * - * Each store gets its own table (of_) with two columns: - * - id (text, primary key) - * - data (jsonb, your tool's data) - * - * For a more production-grade approach with proper schemas, migrations, - * and row-level security, see how ExecuFunction structures its database layer. +/** Optional Postgres cache with ordered asynchronous persistence. + * Sync mutations update the local cache; await flush() to acknowledge queued + * database writes. This is not a distributed atomic store or event journal. */ - -import pg from "pg"; -const { Pool } = pg; +import type pg from "pg"; import type { Store } from "./store.js"; -// ─── Connection Pool (shared across all stores) ──────────────────────────── +/** Small injectable database boundary for deterministic persistence checks. */ +export interface PgStoreDatabase { + query = Record>( + sql: string, + values?: unknown[], + ): Promise<{ rows: T[] }>; +} + +export interface PgStoreOptions { + /** Caller-owned database connection; the shared pool is not initialized. */ + database?: PgStoreDatabase; +} + +export interface PgStore extends Store { + /** Wait for writes queued before this call; rejects on any persistence failure. */ + flush(): Promise; +} -let pool: pg.Pool | null = null; +let poolPromise: Promise | undefined; +let closing: Promise | undefined; +let poolEpoch = 0; +const stores = new Set<{ flush(): Promise; close(): void }>(); -function getPool(): pg.Pool { - if (!pool) { +async function getPool(): Promise { + if (closing) throw new Error("Postgres pool is shutting down"); + if (!poolPromise) { const connectionString = process.env.DATABASE_URL; if (!connectionString) { - throw new Error( - "DATABASE_URL environment variable is required for Postgres stores.\n\n" + - "Set it to your Postgres connection string:\n" + - " export DATABASE_URL=postgres://user:pass@localhost:5432/mydb\n\n" + - "Quick options:\n" + - " - Local Docker: docker run -e POSTGRES_PASSWORD=pass -p 5432:5432 postgres\n" + - " - Supabase: https://supabase.com (free tier)\n" + - " - Neon: https://neon.tech (free tier)\n" + - " - Google Cloud SQL, AWS RDS, etc.\n\n" + - "Don't want a database? Use createStore() instead — it saves to JSON files." - ); + throw new Error("DATABASE_URL is required for Postgres stores. Set it to your Postgres connection string, or use createStore() for JSON files."); } - pool = new Pool({ connectionString }); + const initialization = import("pg").then(({ default: driver }) => + new driver.Pool({ connectionString }), + ).catch((error: unknown) => { + if (error && typeof error === "object" && "code" in error + && error.code === "ERR_MODULE_NOT_FOUND" + && error instanceof Error && error.message.includes("'pg'")) { + throw new Error("Postgres stores require the optional pg package. Install pg before using createPgStore().", { cause: error }); + } + throw error; + }); + poolPromise = initialization; + initialization.catch(() => { + if (poolPromise === initialization) poolPromise = undefined; + }); } - return pool; + return poolPromise; } -/** - * Shut down the connection pool gracefully. - * Call this when your server is shutting down (optional). +/** Flush queued writes and close the shared pool. Does not import pg if unused. + * Persistence errors remain visible here even when a previous flush observed them. */ -export async function closePgPool(): Promise { - if (pool) { - await pool.end(); - pool = null; - } +export function closePgPool(): Promise { + if (closing) return closing; + poolEpoch += 1; + const active = [...stores]; + for (const store of active) store.close(); + const currentPool = poolPromise; + const shutdown = (async () => { + const errors: unknown[] = []; + const flushed = await Promise.allSettled(active.map((store) => store.flush())); + for (const result of flushed) if (result.status === "rejected") errors.push(result.reason); + if (currentPool) { + try { await (await currentPool).end(); } catch (error) { errors.push(error); } + } + for (const store of active) stores.delete(store); + if (poolPromise === currentPool) poolPromise = undefined; + if (errors.length) throw new AggregateError(errors, "Postgres shutdown failed"); + })(); + closing = shutdown; + // Handle cleanup without creating a second unhandled rejected promise. + shutdown.then(() => { closing = undefined; }, () => { closing = undefined; }); + return shutdown; } -// ─── Store Factory ───────────────────────────────────────────────────────── - -/** - * Create a persistent store backed by Postgres. - * - * Same interface as createStore() — your tool code doesn't change. - * The table is created automatically if it doesn't exist. - * Data is loaded into memory on startup for fast reads, and written - * through to Postgres on every set/delete for durability. - * - * @param name - Store name (becomes table "of_", e.g. "tasks" → of_tasks) +/** Create a cache backed by Postgres. Sync API stays compatible with Store. + * All changes are serialized in call order. await flush() before reporting a + * durable success, and do not use this cache for work or conversation journals. */ -export async function createPgStore(name: string): Promise> { - const db = getPool(); - // Prefix with of_ to avoid collisions with existing tables - const table = `of_${name.replace(/[^a-z0-9_]/gi, "_")}`; - - // Create table if it doesn't exist - await db.query(` - CREATE TABLE IF NOT EXISTS ${table} ( - id TEXT PRIMARY KEY, - data JSONB NOT NULL - ) - `); - - // Load all existing data into memory - const { rows } = await db.query<{ id: string; data: T }>( - `SELECT id, data FROM ${table}` - ); - const cache = new Map(rows.map((r) => [r.id, r.data])); - +export async function createPgStore( + name: string, + options: PgStoreOptions = {}, +): Promise> { + if (closing) throw new Error("Postgres pool is shutting down"); + if (typeof name !== "string" || !name) { + throw new Error("Postgres store name must be non-empty"); + } + const startupEpoch = poolEpoch; + const db = options.database ?? await getPool(); + // Preserve legacy punctuation normalization and PostgreSQL case folding. + // Quote the sanitized identifier so it cannot alter the SQL statement. + const table = `"of_${name.replace(/[^a-z0-9_]/gi, "_").toLowerCase()}"`; + await db.query(`CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, data JSONB NOT NULL)`); + const { rows } = await db.query<{ id: string; data: T }>(`SELECT id, data FROM ${table}`); + if (closing || startupEpoch !== poolEpoch) throw new Error("Postgres pool shut down during store initialization"); + const cache = new Map(rows.map((row) => [row.id, row.data])); + const errors: unknown[] = []; + let tail: Promise = Promise.resolve(); + let closed = false; + function assertOpen(): void { + if (closed) throw new Error(`PgStore(${name}) has been closed`); + } + function enqueue(sql: string, values?: unknown[]): void { + tail = tail.then(async () => { + try { await db.query(sql, values); } catch (error) { errors.push(error); } + }); + } + async function flush(): Promise { + const boundary = tail; + await boundary; + if (errors.length) throw new AggregateError([...errors], `PgStore(${name}) persistence failed`); + } + stores.add({ flush, close: () => { closed = true; } }); return { - get(id: string): T | undefined { - return cache.get(id); - }, - - set(id: string, value: T): void { + get: (id) => cache.get(id), + set(id, value) { + assertOpen(); + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new Error("Postgres store values must be JSON serializable"); cache.set(id, value); - // Write through to Postgres (fire-and-forget for sync interface) - db.query( - `INSERT INTO ${table} (id, data) VALUES ($1, $2::jsonb) - ON CONFLICT (id) DO UPDATE SET data = $2::jsonb`, - [id, JSON.stringify(value)] - ).catch((err) => { - console.error(`PgStore(${name}): write failed for "${id}":`, err.message); - }); + enqueue(`INSERT INTO ${table} (id, data) VALUES ($1, $2::jsonb) ON CONFLICT (id) DO UPDATE SET data = $2::jsonb`, [id, serialized]); }, - - delete(id: string): boolean { + delete(id) { + assertOpen(); const existed = cache.delete(id); - if (existed) { - db.query(`DELETE FROM ${table} WHERE id = $1`, [id]).catch((err) => { - console.error(`PgStore(${name}): delete failed for "${id}":`, err.message); - }); - } + if (existed) enqueue(`DELETE FROM ${table} WHERE id = $1`, [id]); return existed; }, - - getAll(): T[] { - return Array.from(cache.values()); - }, - - entries(): [string, T][] { - return Array.from(cache.entries()); - }, - - get size(): number { - return cache.size; - }, - - has(id: string): boolean { - return cache.has(id); - }, - - clear(): void { + getAll: () => [...cache.values()], + entries: () => [...cache.entries()], + get size() { return cache.size; }, + has: (id) => cache.has(id), + clear() { + assertOpen(); cache.clear(); - db.query(`DELETE FROM ${table}`).catch((err) => { - console.error(`PgStore(${name}): clear failed:`, err.message); - }); + enqueue(`DELETE FROM ${table}`); }, + flush, }; } diff --git a/src/framework/pi.ts b/src/framework/pi.ts index 9a45a76..95442f5 100644 --- a/src/framework/pi.ts +++ b/src/framework/pi.ts @@ -8,10 +8,13 @@ import type { ToolRegistry } from "./registry.js"; import type { ToolDefinition, ToolResult } from "./types.js"; +import type { ToolExecutionReceipt } from "./execution-types.js"; import { - executeRuntimeTool, - formatRuntimeToolResult, + executeRuntimeToolDetailed, + runtimeToolContent, + runtimeToolFailureMessage, selectRuntimeTools, + type NativeToolContent, type RuntimeToolSelectionOptions, } from "./runtime-tool-bridge.js"; @@ -20,14 +23,12 @@ export interface PiTextContent { text: string; } -export interface PiToolResult { - content: PiTextContent[]; +export interface PiToolResult { + content: NativeToolContent[]; details: TDetails; } -export interface PiExtensionContextLike { - [key: string]: unknown; -} +export type PiExtensionContextLike = object; export interface PiToolShape { name: string; @@ -87,18 +88,22 @@ export function toolToPi( parameters: tool.inputSchema, ...(promptSnippet === undefined ? {} : { promptSnippet }), ...(promptGuidelines === undefined ? {} : { promptGuidelines }), - async execute(_toolCallId, params, signal, _onUpdate, _context) { - const result = await executeRuntimeTool(registry, tool, params, signal); + async execute(toolCallId, params, signal, _onUpdate, hostContext) { + const context = await options.resolveExecutionContext?.({ tool, toolCallId, signal, hostContext }); + const execution = await executeRuntimeToolDetailed(registry, tool, params, signal, { callId: toolCallId, ...context }); + const { result, receipt } = execution; // Pi marks tool failures only when execute throws. Returning an `isError` // field is ignored by the current runtime. if (!result.success) { - throw new Error(result.error ?? "unknown error"); + const error = new Error(runtimeToolFailureMessage(result)); + Object.assign(error, { executionReceipt: receipt, result }); + throw error; } return options.formatResult ? options.formatResult(result, tool) : { - content: [{ type: "text", text: formatRuntimeToolResult(result) }], - details: result, + content: runtimeToolContent(result), + details: { ...result, executionReceipt: receipt }, }; }, }; diff --git a/src/framework/rag.ts b/src/framework/rag.ts index 3eae277..388673b 100644 --- a/src/framework/rag.ts +++ b/src/framework/rag.ts @@ -26,8 +26,6 @@ */ import { randomUUID } from "node:crypto"; -import pg from "pg"; -const { Pool } = pg; import type { ToolDefinition } from "./types.js"; import { defineTool, ok, err } from "./tool.js"; @@ -219,7 +217,8 @@ export async function createRAG(options: RAGOptions): Promise { ); } - const pool = new Pool({ connectionString: databaseUrl }); + const { default: pg } = await import("pg"); + const pool = new pg.Pool({ connectionString: databaseUrl }); const docsTable = `${tablePrefix}_documents`; const chunksTable = `${tablePrefix}_chunks`; @@ -310,7 +309,7 @@ export async function createRAG(options: RAGOptions): Promise { client.release(); } - console.log(`📄 Added document "${id}" (${chunks.length} chunk${chunks.length === 1 ? "" : "s"})`); + console.error(`📄 Added document "${id}" (${chunks.length} chunk${chunks.length === 1 ? "" : "s"})`); return doc; } diff --git a/src/framework/registry.ts b/src/framework/registry.ts index 63bac93..474ee13 100644 --- a/src/framework/registry.ts +++ b/src/framework/registry.ts @@ -17,11 +17,26 @@ import type { AnthropicTool, OpenAIFunction, } from "./types.js"; -import { validateParams, formatValidationErrors } from "./validate.js"; +import { ToolExecutor, capabilityDigest } from "./executor.js"; +import { snapshotJson } from "./session.js"; +import type { ExecutionJournalSink, ExecutionPolicy, RegistrySelection, ToolExecution, ToolExecutionContext, ToolExecutorOptions } from "./execution-types.js"; export class ToolRegistry { // eslint-disable-next-line @typescript-eslint/no-explicit-any private tools: Map> = new Map(); + private readonly executor: ToolExecutor; + private readonly policies: readonly ExecutionPolicy[]; + private readonly capturedDigests = new Map(); + private readonly origins = new Map ToolDefinition | undefined>(); + private readonly options: Readonly; + + constructor(options: ToolExecutorOptions = {}, shared?: { executor: ToolExecutor; policies: readonly ExecutionPolicy[] }) { + this.options = Object.freeze({ ...options, logger: options.logger ?? { + warn: (message: string) => console.warn(message), error: (message: string) => console.error(message), + } }); + this.executor = shared?.executor ?? new ToolExecutor(name => this.tools.get(name), this.options); + this.policies = shared?.policies ?? []; + } // ── Registration ───────────────────────────────────────────────────────── @@ -38,17 +53,27 @@ export class ToolRegistry { opts: { overwrite?: boolean } = {}, ): void { if (this.tools.has(tool.name)) { + if (this.options.mode === "governed") throw new Error(`Governed registry collision: ${tool.name}`); if (opts.overwrite === false) { - console.warn( + this.options.logger?.warn?.( `⚠️ Tool "${tool.name}" is already registered — keeping existing definition (skipping new one).`, ); return; } - console.warn( + this.options.logger?.warn?.( `⚠️ Tool "${tool.name}" is already registered — overwriting.`, ); } - this.tools.set(tool.name, tool); + const stored = this.options.mode === "governed" ? Object.freeze({ ...tool, + inputSchema: snapshotJson(tool.inputSchema) as ToolDefinition["inputSchema"], + ...(tool.outputSchema ? { outputSchema: snapshotJson(tool.outputSchema) as ToolDefinition["inputSchema"] } : {}), + ...(tool.annotations ? { annotations: snapshotJson(tool.annotations) } : {}), + ...(tool._meta ? { _meta: snapshotJson(tool._meta) } : {}), + ...(tool.contract ? { contract: snapshotJson(tool.contract) } : {}), + }) : tool; + this.tools.set(tool.name, stored); + this.origins.delete(tool.name); + this.capturedDigests.delete(tool.name); } /** Register multiple tools at once. See register() for opts. */ @@ -68,6 +93,8 @@ export class ToolRegistry { * destroy agents don't accumulate ghost tools in shared registries. */ unregister(name: string): boolean { + this.origins.delete(name); + this.capturedDigests.delete(name); return this.tools.delete(name); } @@ -95,76 +122,55 @@ export class ToolRegistry { // ── Execution ──────────────────────────────────────────────────────────── - /** Execute a tool by name with the given parameters */ - async execute( - name: string, - params: Record, - ): Promise { - const tool = this.tools.get(name); - if (!tool) { - return { success: false, error: `Unknown tool: "${name}"` }; - } + /** Compatible facade returning the canonical JSON-safe result. */ + async execute(name: string, params: Record, context: ToolExecutionContext = {}): Promise { + return (await this.executeDetailed(name, params, context)).result; + } - // ── Validate parameters against schema ──────────────────────────────── - const validationErrors = validateParams(params, tool.inputSchema); - if (validationErrors.length > 0) { - const message = formatValidationErrors(name, validationErrors); - console.error(`\n⚠️ ${message}\n`); - return { success: false, error: message, executionOutcome: "failed" }; - } + executeDetailed(name: string, params: Record, context: ToolExecutionContext = {}, lifecycle?: ExecutionJournalSink): Promise { + const captured = this.capturedDigests.get(name); + return this.executor.executeDetailed(name, params, context, { + resolve: selected => this.tools.has(selected) + ? (this.origins.has(selected) ? this.origins.get(selected)!() : this.tools.get(selected)) : undefined, + policies: this.policies, lifecycle, + catalogDigest: this.options.mode === "governed" ? captured : undefined, + }); + } - const start = Date.now(); - try { - const result = await tool.handler(params); - const duration = Date.now() - start; - // The handler returning is not proof that its declared result survived - // the model/journal serialization boundary. Keep this executor log - // factual; the harness records the canonical outcome after normalization. - console.log(`↩️ ${name} returned in ${duration}ms`); - return result; - } catch (error) { - const duration = Date.now() - start; - const message = - error instanceof Error ? error.message : "Unknown error"; - const stack = - error instanceof Error ? error.stack?.split("\n")[1]?.trim() : ""; - - // ── Rich error output ────────────────────────────────────────────── - console.error(`\n❓ ${name} threw after ${duration}ms; outcome is unknown`); - console.error(` Error: ${message}`); - // Tool inputs may contain credentials or personal data. Log shape only. - console.error(` Input keys: ${Object.keys(params).sort().join(", ") || "(none)"}`); - if (stack) { - console.error(` At: ${stack}`); - } + executeBatch(calls: readonly { name: string; args: Record; id?: string }[], context: ToolExecutionContext = {}): Promise { + return Promise.all(calls.map(call => this.executeDetailed(call.name, call.args, { ...context, callId: call.id ?? context.callId }))); + } - // Provide hints for common mistakes - const hints: string[] = []; - if (message.includes("Cannot read properties of undefined")) { - hints.push( - "A parameter might be missing — check that required fields are provided" - ); - } - if (message.includes("is not a function")) { - hints.push( - "Check that you're importing the right modules and calling functions correctly" - ); - } - if (message.includes("fetch") || message.includes("ECONNREFUSED")) { - hints.push( - "Network request failed — check the URL and your internet connection" - ); - } - if (message.includes("ENOENT")) { - hints.push("File not found — check the file path"); - } - for (const hint of hints) { - console.error(` Hint: ${hint}`); - } - console.error(); + withChildExecution(callback: () => Promise): Promise { + return this.executor.withChildExecution(callback); + } + + /** A private host capability bound to one adapter-owned lease-renewal tool. */ + createRenewalRunner(tool: ToolDefinition, lifecycle?: ExecutionJournalSink) { + return this.executor.createRenewalRunner(tool, this.policies, lifecycle); + } - return { success: false, error: message, executionOutcome: "unknown" }; + /** Visibility ceiling with the same executor, scheduling domain, and policy. */ + project(selection: RegistrySelection = {}, policy?: ExecutionPolicy): ToolRegistry { + const projected = new ToolRegistry(this.options, { + executor: this.executor, policies: [...this.policies, ...(policy ? [policy] : [])], + }); + for (const tool of this.getAll()) { + if (selection.names && !selection.names.includes(tool.name)) continue; + if (selection.tags && !selection.tags.some(tag => tool.tags?.includes(tag))) continue; + if (selection.capabilities && !selection.capabilities.some(tag => tool.tags?.includes(tag) || tool.name === tag)) continue; + const isRead = tool.contract?.mutation === false || (tool.contract?.sideEffects.length === 0 && tool.contract?.mutation !== true); + if (selection.level === "read" && !isRead) continue; + if (selection.level === "write" && isRead) continue; + if (selection.filter && !selection.filter(tool)) continue; + projected.tools.set(tool.name, tool); + projected.origins.set(tool.name, () => { + if (!this.tools.has(tool.name)) return undefined; + return this.origins.has(tool.name) ? this.origins.get(tool.name)!() : this.tools.get(tool.name); + }); + projected.capturedDigests.set(tool.name, capabilityDigest(tool)); } + return projected; } // ── Provider Format Adapters ───────────────────────────────────────────── diff --git a/src/framework/runs.ts b/src/framework/runs.ts index 567b427..adc2a23 100644 --- a/src/framework/runs.ts +++ b/src/framework/runs.ts @@ -9,6 +9,7 @@ import { createHash, randomUUID } from "node:crypto"; import type { AIAdapter } from "./adapters/types.js"; import type { ToolDefinition } from "./types.js"; +import type { ContextSnapshotReference } from "./context.js"; import { customAdapterSelection, type ModelSelection, @@ -43,6 +44,7 @@ export interface RunEnvironmentRef { } export interface RunManifest { + contextSnapshotRefs?: ContextSnapshotReference[]; schemaVersion: typeof RUN_SCHEMA_VERSION; runId: string; taskId?: string; @@ -91,6 +93,8 @@ export interface RunFailure { } export interface RunToolEffectReceipt { + executionReceiptId?: string; + verificationRefs?: readonly string[]; name: string; args: Record; result: { @@ -109,6 +113,7 @@ export interface RunToolEffects { } export interface RunRecord { + executionReceiptRefs?: readonly string[]; manifest: RunManifest; status: RunStatus; startedAt: string; @@ -208,6 +213,7 @@ export interface GoalTransition { } export interface RunContext { + contextSnapshotRefs?: ContextSnapshotReference[]; runId?: string; taskId?: string; workItemId?: string; @@ -304,6 +310,7 @@ export function createRunManifest(input: CreateRunManifestInput): RunRecord { correlationId: input.correlationId ?? randomUUID(), attempt: input.attempt ?? 1, actor: input.actor, + ...(input.contextSnapshotRefs === undefined ? {} : { contextSnapshotRefs: structuredClone(input.contextSnapshotRefs) }), model, instructionsDigest: digestValue(input.instructions), ...(input.taskContractVersion !== undefined && { diff --git a/src/framework/runtime-tool-bridge.ts b/src/framework/runtime-tool-bridge.ts index a8ebfff..bc7c897 100644 --- a/src/framework/runtime-tool-bridge.ts +++ b/src/framework/runtime-tool-bridge.ts @@ -1,5 +1,6 @@ import type { ToolRegistry } from "./registry.js"; import type { ToolDefinition, ToolResult } from "./types.js"; +import type { ToolExecution, ToolExecutionContext } from "./execution-types.js"; /** Shared selection controls used by native agent-runtime bridges. */ export interface RuntimeToolSelectionOptions { @@ -9,6 +10,13 @@ export interface RuntimeToolSelectionOptions { namePrefix?: string; /** Compute a human-readable label. Defaults to the registry tool name. */ label?: (tool: ToolDefinition) => string; + /** Trusted application callback; tool arguments never supply authority. */ + resolveExecutionContext?: (input: { + tool: ToolDefinition; + toolCallId?: string; + signal?: AbortSignal; + hostContext?: unknown; + }) => ToolExecutionContext | Promise; } export interface SelectedRuntimeTool { @@ -47,14 +55,57 @@ export async function executeRuntimeTool( tool: ToolDefinition, params: unknown, signal?: AbortSignal, + context: ToolExecutionContext = {}, ): Promise { - signal?.throwIfAborted(); - const result = await registry.execute( - tool.name, - normalizeRuntimeParams(params), - ); - signal?.throwIfAborted(); - return result; + return (await executeRuntimeToolDetailed(registry, tool, params, signal, context)).result; +} + +export async function executeRuntimeToolDetailed( + registry: ToolRegistry, + tool: ToolDefinition, + params: unknown, + signal?: AbortSignal, + context: ToolExecutionContext = {}, +): Promise { + const signals = [signal, context.signal].filter((value): value is AbortSignal => value !== undefined); + return registry.executeDetailed(tool.name, normalizeRuntimeParams(params), { + ...context, + ...(signals.length ? { signal: AbortSignal.any(signals) } : {}), + }); +} + +/** These hosts support text and images. Other MCP blocks get explicit text projections. */ +export type NativeToolContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +export function runtimeToolContent(result: ToolResult): NativeToolContent[] { + const content: NativeToolContent[] = result.content?.map((block): NativeToolContent => { + if (block.type === "text") return { type: "text", text: block.text }; + if (block.type === "image") return { type: "image", data: block.data, mimeType: block.mimeType }; + if (block.type === "resource") { + return { + type: "text", + text: `[Resource ${block.resource.uri}; embedded resource metadata is not supported by this host]` + + ("text" in block.resource ? `\n${block.resource.text}` : "\n[Binary resource omitted]"), + }; + } + if (block.type === "resource_link") { + return { type: "text", text: `[Resource link: ${block.uri}; link metadata is not supported by this host]` }; + } + return { type: "text", text: `[Unsupported ${block.type} content omitted by this host]` }; + }) ?? [{ type: "text", text: formatRuntimeToolResult(result) }]; + if (result.executionOutcome === "unknown") { + content.push({ type: "text", text: "Tool execution outcome is unknown; verify side effects before deciding whether any retry is safe." }); + } + return content; +} + +export function runtimeToolFailureMessage(result: ToolResult): string { + const message = result.error ?? "unknown error"; + return result.executionOutcome === "unknown" + ? `${message}; tool execution outcome is unknown; verify side effects before deciding whether any retry is safe` + : message; } /** Human/model-readable text used by text-only host result contracts. */ diff --git a/src/framework/server.ts b/src/framework/server.ts index fc86bae..5380318 100644 --- a/src/framework/server.ts +++ b/src/framework/server.ts @@ -1,114 +1,134 @@ -/** - * OpenFunction — MCP Server - * - * Wraps the Model Context Protocol SDK to expose your tools as an MCP server. - * Any MCP client (Claude Desktop, Cursor, Claude.ai, etc.) can connect to this. - * - * Derived from ExecuFunction's MCP server pattern. The production version - * is ~3,500 lines — this is the essential ~80 lines that do the same job. - */ - +/** MCP projection of the shared OpenFunction executor. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { CallToolRequestSchema, ListToolsRequestSchema, + type CallToolRequest, + type CallToolResult, + type ServerRequest, + type ServerNotification, } from "@modelcontextprotocol/sdk/types.js"; import type { ToolRegistry } from "./registry.js"; +import type { ToolExecutionContext } from "./execution-types.js"; + +export interface McpRequestContext { + request: CallToolRequest; + extra: RequestHandlerExtra; +} export interface ServerOptions { - /** Server name shown to MCP clients */ name: string; - /** Server version */ version?: string; + /** Trusted application binding. Client arguments and _meta never confer authority. */ + resolveContext?: (request: McpRequestContext) => + ToolExecutionContext | undefined | Promise; } -/** - * Start an MCP server that exposes all registered tools. - * - * This function never returns — it runs until the process exits. - * - * @example - * ```ts - * import { registry } from './framework/registry.js'; - * import { startServer } from './framework/server.js'; - * import './my-tools/index.js'; // registers tools as a side effect - * - * startServer(registry, { name: 'my-mcp-server' }); - * ``` - */ -export async function startServer( +export interface McpServerHandle { + server: Server; + /** Own one transport. Omit it to connect standard input/output. */ + connect(transport?: Transport): Promise; + close(): Promise; +} + +/** Construct without attaching process input/output; callers control lifecycle. */ +export function createMcpServer( toolRegistry: ToolRegistry, options: ServerOptions, -): Promise { +): McpServerHandle { const server = new Server( - { - name: options.name, - version: options.version ?? "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - }, + { name: options.name, version: options.version ?? "1.0.0" }, + { capabilities: { tools: {} } }, ); - // ── List Tools ───────────────────────────────────────────────────────── - // When a client connects, it asks "what tools do you have?" - // We return all registered tools with their schemas. - - server.setRequestHandler(ListToolsRequestSchema, async () => { - const tools = toolRegistry.getAll().map((tool) => ({ + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: toolRegistry.getAll().map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, - })); - return { tools }; - }); - - // ── Call Tool ────────────────────────────────────────────────────────── - // When the AI decides to use a tool, the client sends the tool name - // and arguments here. We execute and return the result. - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - const result = await toolRegistry.execute(name, args ?? {}); + ...(tool.annotations === undefined ? {} : { annotations: tool.annotations }), + ...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }), + ...(tool._meta === undefined ? {} : { _meta: tool._meta }), + })), + })); - if (!result.success) { - return { - content: [{ type: "text", text: `Error: ${result.error}` }], - isError: true, - }; + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const resolved = await options.resolveContext?.({ request, extra }); + const context: ToolExecutionContext = { + ...resolved, + correlationId: resolved?.correlationId ?? String(extra.requestId), + signal: resolved?.signal + ? AbortSignal.any([extra.signal, resolved.signal]) + : extra.signal, + }; + const execution = await toolRegistry.executeDetailed( + request.params.name, request.params.arguments ?? {}, context, + ); + const { result, receipt } = execution; + const content: CallToolResult["content"] = result.content === undefined + ? [{ + type: "text", + text: result.success + ? typeof result.data === "string" + ? result.data + : JSON.stringify(result.data, null, 2) ?? "null" + : result.executionOutcome === "unknown" + ? `Execution outcome is unknown: ${result.error ?? "Unable to confirm the effect"}. Reconcile the target state before retrying.` + : `Error: ${result.error ?? "Tool execution failed"}`, + }] + : [...result.content]; + if (result.content !== undefined && result.executionOutcome === "unknown") { + content.push({ type: "text", text: "Execution outcome is unknown. Reconcile the target state before retrying." }); } - - // Format the result as text for the AI to read - const text = - typeof result.data === "string" - ? result.data - : JSON.stringify(result.data, null, 2); - - const content: Array<{ type: "text"; text: string }> = [ - { type: "text", text }, - ]; - - // Include the human-friendly message if present - if (result.message) { - content.push({ type: "text", text: result.message }); - } - - return { content }; + if (result.message) content.push({ type: "text", text: result.message }); + // Keep the provider payload exactly aligned with its advertised schema. + // The receipt is separate metadata, never a wrapper around structuredContent. + return { + content, + ...(result.structuredContent === undefined ? {} : { + structuredContent: result.structuredContent, + }), + ...(!result.success ? { isError: true } : {}), + _meta: { + ...result._meta, + "openfunction/execution": receipt, + }, + } satisfies CallToolResult; }); - // ── Start ────────────────────────────────────────────────────────────── - const transport = new StdioServerTransport(); - await server.connect(transport); + let connected = false; + let closed = false; + return { + server, + async connect(transport = new StdioServerTransport()) { + if (closed) throw new Error("MCP server is closed"); + if (connected) throw new Error("MCP server is already connected"); + connected = true; + try { + await server.connect(transport); + } catch (error) { + await server.close(); + closed = true; + throw error; + } + }, + async close() { + if (closed) return; + closed = true; + await server.close(); + }, + }; +} - const toolCount = toolRegistry.getAll().length; - console.error( - `🔌 ${options.name} MCP server started with ${toolCount} tools`, - ); - console.error( - ` Tools: ${toolRegistry.listNames().join(", ")}`, - ); +/** Compatible stdio entry point. Use createMcpServer for deliberate shutdown. */ +export async function startServer( + toolRegistry: ToolRegistry, + options: ServerOptions, +): Promise { + const handle = createMcpServer(toolRegistry, options); + await handle.connect(); + console.error(`${options.name} MCP server started with ${toolRegistry.getAll().length} tools`); } diff --git a/src/framework/session.ts b/src/framework/session.ts index 2d029be..3662f5d 100644 --- a/src/framework/session.ts +++ b/src/framework/session.ts @@ -29,13 +29,18 @@ import { TextDecoder } from "node:util"; import type { AdapterContinuationRecovery, + AdapterUsage, + AdapterRequestLimits, AdapterReplayPayload, AdapterSessionState, ChatMessage, ToolCall, } from "./adapters/types.js"; +import type { ContextSnapshot } from "./context.js"; +import type { ToolExecutionReceipt } from "./execution-types.js"; -export const SESSION_EVENT_SCHEMA_VERSION = 1 as const; +export const SESSION_EVENT_SCHEMA_VERSION = 2 as const; +export type SessionEventSchemaVersion = 1 | 2; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; @@ -65,6 +70,7 @@ export interface ModelRequestSnapshot { systemPromptSha256: string; tools: ModelToolSnapshotReference; options?: unknown; + contextSnapshots?: ContextSnapshot[]; adapter?: { name: string; model: string; @@ -72,6 +78,8 @@ export interface ModelRequestSnapshot { } export interface ModelResponseSnapshot { + usage?: AdapterUsage; + requestLimits?: AdapterRequestLimits; text?: string; toolCall?: ToolCall; toolCalls?: ToolCall[]; @@ -117,10 +125,12 @@ export interface SessionEventDataMap { }; "tool/execution-started": { call: ToolCall; + receipt?: ToolExecutionReceipt; }; "tool/result": { toolCallId: string; toolName: string; + receipt?: ToolExecutionReceipt; outcome: ToolResultOutcome; result?: unknown; error?: string; @@ -172,7 +182,7 @@ export interface SessionEventContext { } interface SessionEventEnvelope extends SessionEventContext { - readonly schemaVersion: typeof SESSION_EVENT_SCHEMA_VERSION; + readonly schemaVersion: SessionEventSchemaVersion; readonly sessionId: string; readonly seq: number; readonly eventId: string; @@ -343,6 +353,11 @@ export class JsonlSessionEventStore implements SessionEventStore { if (!createdEventFile && !sameEventFileIdentity(current.identity, openedIdentity)) { throw new SessionConcurrencyError(event.sessionId); } + if (event.schemaVersion === 2 && current.events.length > 0 + && current.events.at(-1)?.schemaVersion === 1 + && current.events.every((prior) => prior.schemaVersion === 1)) { + preserveV1Bytes(paths.events, eventFd, this.rootDir); + } if (current.hasTornTail) { ftruncateSync(eventFd, current.committedByteLength); } @@ -1437,6 +1452,9 @@ interface ValidationOpenTool { argsSha256?: string; callRecorded: boolean; executionStarted: boolean; + executionId?: string; + capabilityDigest?: string; + executionContextDigest?: string; resultModelContent?: string; turnId: string; stepId: string; @@ -1444,6 +1462,43 @@ interface ValidationOpenTool { correlationId?: string; } +function assertBoundExecutionReceipt( + receipt: ToolExecutionReceipt, + tool: ValidationOpenTool, + callId: string, + event: SessionEvent, +): void { + if (!isPlainObject(receipt) || receipt.schemaVersion !== 1) { + throw new SessionInvariantError("unsupported execution receipt schema"); + } + assertNonEmptyString(receipt.executionId, "receipt execution id"); + if (receipt.callId !== callId || receipt.toolName !== tool.name + || receipt.inputDigest !== tool.argsSha256 + || receipt.runId !== event.runId || receipt.correlationId !== event.correlationId) { + throw new SessionInvariantError("execution receipt does not bind the tool call/name/arguments/context"); + } + if (tool.executionId !== undefined && receipt.executionId !== tool.executionId) { + throw new SessionInvariantError("execution receipt identity changed after start"); + } + if (tool.capabilityDigest !== undefined && receipt.capabilityDigest !== tool.capabilityDigest) { + throw new SessionInvariantError("execution capability changed after start"); + } + if (tool.executionContextDigest !== undefined && tool.executionContextDigest !== digestJson({ + actor: receipt.actor ?? null, scope: receipt.scope ?? null, + authorizationContextId: receipt.authorizationContextId ?? null, + })) { + throw new SessionInvariantError("execution receipt actor/scope binding changed after start"); + } + if (receipt.phase === "not_started" && receipt.startedAt !== undefined) { + throw new SessionInvariantError("not-started receipt cannot assert a handler start time"); + } + if (!( ["not_started", "started", "settled"] as string[]).includes(receipt.phase) + || !( ["allowed", "rejected"] as string[]).includes(receipt.admission) + || !( ["succeeded", "failed", "unknown"] as string[]).includes(receipt.executionOutcome)) { + throw new SessionInvariantError("invalid execution receipt lifecycle"); + } +} + interface ValidationPendingModelRequest { seq: number; turnId: string; @@ -1513,7 +1568,7 @@ function validateEventEnvelope( ); } if (!isPlainObject(event)) throw new SessionInvariantError("session event must be a JSON object"); - if (event.schemaVersion !== SESSION_EVENT_SCHEMA_VERSION) { + if (event.schemaVersion !== 1 && event.schemaVersion !== SESSION_EVENT_SCHEMA_VERSION) { throw new SessionInvariantError(`unsupported session event schema version: ${String(event.schemaVersion)}`); } if (event.sessionId !== expectedSessionId) { @@ -1691,6 +1746,12 @@ function validateCorrelationTransition( `tool execution ${event.data.call.id} was already started`, ); } + if (event.data.receipt !== undefined) { + assertBoundExecutionReceipt(event.data.receipt, tool, event.data.call.id, event); + if (event.data.receipt.phase !== "started" || event.data.receipt.admission !== "allowed") { + throw new SessionInvariantError("execution-started requires an allowed started receipt"); + } + } break; } case "tool/result": { @@ -1719,9 +1780,36 @@ function validateCorrelationTransition( ); } } else if (!tool.executionStarted) { - throw new SessionInvariantError( - `tool result ${event.data.toolCallId} has no preceding execution-started event`, - ); + const receipt = event.data.receipt; + if (event.schemaVersion !== 2 || !tool.callRecorded || receipt === undefined + || receipt.phase !== "not_started" || receipt.admission !== "rejected" + || receipt.executionOutcome !== "failed") { + throw new SessionInvariantError( + `tool result ${event.data.toolCallId} has no preceding execution-started event or bound not-started rejection receipt`, + ); + } + } + if (event.data.receipt !== undefined) { + assertBoundExecutionReceipt(event.data.receipt, tool, event.data.toolCallId, event); + if (event.data.receipt.executionOutcome !== event.data.outcome) { + throw new SessionInvariantError("tool result receipt outcome does not match result"); + } + if (tool.executionStarted) { + const receipt = event.data.receipt; + // V2's start event is durable dispatch intent. A trusted, identically + // bound receipt can establish that cancellation/revalidation stopped + // dispatch after that intent. V1 remains conservatively started. + const stoppedBeforeHandler = event.schemaVersion === 2 + && receipt.phase === "not_started" && receipt.admission === "rejected" + && receipt.executionOutcome === "failed"; + if (!stoppedBeforeHandler && (receipt.admission !== "allowed" || receipt.phase === "not_started")) { + throw new SessionInvariantError("started intent requires an allowed result or bound v2 not-started rejection receipt"); + } + } + if (event.data.result !== undefined && event.data.receipt.resultDigest !== undefined + && event.data.receipt.resultDigest !== digestJson(event.data.result)) { + throw new SessionInvariantError("tool result does not match receipt digest"); + } } break; } @@ -2171,6 +2259,12 @@ function advanceSessionValidationState( case "tool/execution-started": { const tool = state.openTools.get(event.data.call.id)!; tool.executionStarted = true; + tool.executionId = event.data.receipt?.executionId; + tool.capabilityDigest = event.data.receipt?.capabilityDigest; + tool.executionContextDigest = event.data.receipt === undefined ? undefined : digestJson({ + actor: event.data.receipt.actor ?? null, scope: event.data.receipt.scope ?? null, + authorizationContextId: event.data.receipt.authorizationContextId ?? null, + }); break; } case "tool/result": { @@ -2771,6 +2865,38 @@ function isPlainObject(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } +/** An immutable pre-upgrade copy preserves every original v1 byte. */ +function preserveV1Bytes(eventsPath: string, eventsFd: number, rootDir: string): void { + const backupPath = `${eventsPath}.pre-v2`; + const bytes = readFileSync(eventsFd); + const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW; + const verifyExisting = (): void => { + const fd = openSync(backupPath, constants.O_RDONLY | noFollow); + try { + eventFileIdentityFromStats(fstatSync(fd, { bigint: true }), backupPath); + if (!readFileSync(fd).equals(bytes)) { + throw new SessionInvariantError("existing pre-v2 backup does not match original journal bytes"); + } + } finally { closeSync(fd); } + }; + if (eventFileIdentity(backupPath) !== undefined) { verifyExisting(); return; } + const temporary = `${backupPath}.${randomUUID()}.tmp`; + const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600); + try { + fchmodSync(fd, 0o600); + writeAll(fd, bytes); + fsyncSync(fd); + } finally { closeSync(fd); } + try { + try { linkSync(temporary, backupPath); } + catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") throw error; + verifyExisting(); + } + fsyncDirectory(rootDir); + } finally { unlinkSync(temporary); } +} + function writeAll(fd: number, bytes: Buffer): void { let offset = 0; while (offset < bytes.length) { diff --git a/src/framework/store.ts b/src/framework/store.ts index 7c526cd..df934cd 100644 --- a/src/framework/store.ts +++ b/src/framework/store.ts @@ -11,7 +11,8 @@ * tasks.getAll(); // → [{ title: "Read chapter 5", ... }] * tasks.delete("1"); * - * Data is saved to .data/.json in the project root. + * Data is saved to .data/.json under the caller working directory. + * Pass { dataDir } to reopen an existing directory explicitly. * You can open these files to see exactly what's stored. */ @@ -32,13 +33,15 @@ import { unlinkSync, writeSync, } from "node:fs"; -import { basename, join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { basename, join, dirname, resolve } from "node:path"; -// Resolve project root (two levels up from src/framework/) -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const DATA_DIR = join(__dirname, "..", "..", ".data"); +export interface StoreOptions { + /** Directory owned by the caller. Defaults to process.cwd()/.data. + * Pass the previous repository .data path explicitly when reopening old state. + * Existing stores are never copied or merged automatically. + */ + dataDir?: string; +} const LOCK_WAIT_TIMEOUT_MS = 30_000; const LOCK_POLL_INTERVAL_MS = 10; const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)); @@ -112,9 +115,10 @@ class StorePersistenceError extends Error { * * @param name - Store name (used as the filename, e.g. "tasks" → .data/tasks.json) */ -export function createStore(name: string): Store { +export function createStore(name: string, options: StoreOptions = {}): Store { validateStoreName(name); - ensurePrivateDataDirectory(); + const DATA_DIR = resolve(options.dataDir ?? join(process.cwd(), ".data")); + ensurePrivateDataDirectory(DATA_DIR); const filePath = join(DATA_DIR, `${name}.json`); const lockPath = `${filePath}.lock`; @@ -320,7 +324,7 @@ function validateStoreName(name: string): void { } } -function ensurePrivateDataDirectory(): void { +function ensurePrivateDataDirectory(DATA_DIR: string): void { let created = false; try { @@ -420,6 +424,7 @@ function normalizeStoreFile(filePath: string): void { } function persistData(filePath: string, data: Map): void { + const DATA_DIR = dirname(filePath); const serialized = serializeData(data, filePath); const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; let fd: number | undefined; @@ -456,6 +461,7 @@ function persistData(filePath: string, data: Map): void { * liveness check still proves the owner dead immediately before unlinking. */ function reclaimStaleTempFiles(filePath: string): void { + const DATA_DIR = dirname(filePath); const fileName = basename(filePath); const pattern = new RegExp( `^${escapeRegExp(fileName)}\\.([1-9][0-9]*)\\.([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\\.tmp$`, @@ -562,6 +568,7 @@ function serializeData(data: Map, filePath: string): string { * waited out; claims owned by dead processes are reclaimed. */ function acquireExclusiveLock(lockPath: string, storeName: string): StoreLock { + const DATA_DIR = dirname(lockPath); const token = randomUUID(); const claimPath = `${lockPath}.${process.pid}.${token}.claim`; const owner: StoreLockOwner = { pid: process.pid, token }; @@ -676,6 +683,7 @@ function tryAcquireReclaimGate( claimPath: string, storeName: string, ): StoreLock | undefined { + const DATA_DIR = dirname(lockPath); // Recovery gates form an append-only generation chain. A crashed gate owner // is never unlinked or replaced: contenders advance to a child generation // keyed by that owner's unique token. This avoids recreating the same ABA diff --git a/src/framework/test-runner.ts b/src/framework/test-runner.ts index 86e80e9..28f7d8c 100644 --- a/src/framework/test-runner.ts +++ b/src/framework/test-runner.ts @@ -132,7 +132,7 @@ async function runSingleTest( const base = { tool: tool.name, test: test.name }; try { - const result = await registry.execute(tool.name, test.input); + const { result } = await registry.executeDetailed(tool.name, test.input); // Check success/failure if (result.success !== test.expect.success) { diff --git a/src/framework/tool-scheduler.ts b/src/framework/tool-scheduler.ts new file mode 100644 index 0000000..8dee1a8 --- /dev/null +++ b/src/framework/tool-scheduler.ts @@ -0,0 +1,170 @@ +/** One scheduling domain shared by an executor and all its registry projections. */ +export type ToolSchedulingMode = "parallel-safe" | "exclusive"; + +export interface ToolScheduleOptions { + signal?: AbortSignal; + /** Absolute deadline in milliseconds since the epoch. */ + deadline?: number | Date; + clock?: () => number; +} + +/** A private capability given only to the operation holding a reservation. */ +export interface BoundToolScheduler { + run( + mode: ToolSchedulingMode, + operation: (child: BoundToolScheduler) => Promise, + options?: ToolScheduleOptions, + ): Promise; +} + +type Entry = { + mode: ToolSchedulingMode; + options: ToolScheduleOptions; + start: () => void; + reject: (error: unknown) => void; + cleanup: () => void; +}; + +function admissionError(options: ToolScheduleOptions): unknown | undefined { + if (options.signal?.aborted) { + return options.signal.reason ?? new DOMException("Tool execution cancelled", "AbortError"); + } + const deadline = options.deadline instanceof Date + ? options.deadline.getTime() : options.deadline; + if (deadline !== undefined && (!Number.isFinite(deadline) + || (options.clock ?? Date.now)() >= deadline)) { + return new DOMException("Tool execution deadline exceeded", "TimeoutError"); + } + return undefined; +} + +export class ToolScheduler { + readonly maxParallel: number; + private queue: Entry[] = []; + private readers = 0; + private writer = false; + + constructor(options: { maxParallel?: number } = {}) { + const cap = options.maxParallel ?? 4; + this.maxParallel = Number.isFinite(cap) && Number.isInteger(cap) && cap > 0 ? cap : 1; + } + + /** FIFO admission: an exclusive entry is a barrier for all later readers. */ + run( + mode: ToolSchedulingMode, + operation: (child: BoundToolScheduler) => Promise, + options: ToolScheduleOptions = {}, + ): Promise { + const error = admissionError(options); + if (error !== undefined) return Promise.reject(error); + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const cancel = () => { + const index = this.queue.indexOf(entry); + if (index < 0) return; // Once admitted, cancellation must not erase its result. + this.queue.splice(index, 1); + entry.cleanup(); + reject(admissionError(options) ?? new DOMException("Tool execution cancelled", "AbortError")); + this.drain(); + }; + const entry: Entry = { + mode, options, reject, + cleanup: () => { + options.signal?.removeEventListener("abort", cancel); + if (timer !== undefined) clearTimeout(timer); + }, + start: () => { + entry.cleanup(); + if (mode === "exclusive") this.writer = true; + else this.readers += 1; + void this.withReservation(mode, operation, options).then(resolve, reject).finally(() => { + if (mode === "exclusive") this.writer = false; + else this.readers -= 1; + this.drain(); + }); + }, + }; + this.queue.push(entry); + options.signal?.addEventListener("abort", cancel, { once: true }); + const deadline = options.deadline instanceof Date ? options.deadline.getTime() : options.deadline; + if (deadline !== undefined) { + const checkDeadline = () => { + const delay = deadline - (options.clock ?? Date.now)(); + if (delay <= 0) cancel(); + else { + timer = setTimeout(checkDeadline, Math.min(delay, 2_147_483_647)); + timer.unref?.(); + } + }; + checkDeadline(); + } + this.drain(); + }); + } + + private drain(): void { + if (this.writer) return; + while (this.queue.length > 0) { + const next = this.queue[0]; + const error = admissionError(next.options); + if (error !== undefined) { + this.queue.shift(); + next.cleanup(); + next.reject(error); + continue; + } + if (next.mode === "exclusive") { + if (this.readers > 0) return; + this.queue.shift(); + next.start(); + return; + } + if (this.readers >= this.maxParallel) return; + this.queue.shift(); + next.start(); + } + } + + private async withReservation( + mode: ToolSchedulingMode, + operation: (child: BoundToolScheduler) => Promise, + inherited: ToolScheduleOptions, + ): Promise { + let active = true; + // Siblings share one slot and run in order. Descendants use their own + // bound capability, so a parent awaiting its child never queues behind itself. + let tail: Promise = Promise.resolve(); + const bound: BoundToolScheduler = Object.freeze({ + run: (childMode: ToolSchedulingMode, childOperation: (child: BoundToolScheduler) => Promise, options: ToolScheduleOptions = {}) => { + if (!active) return Promise.reject(new Error("Tool reservation is no longer active")); + if (mode === "parallel-safe" && childMode === "exclusive") { + return Promise.reject(new Error("Cannot upgrade a parallel-safe reservation to exclusive")); + } + const parentDeadline = inherited.deadline instanceof Date ? inherited.deadline.getTime() : inherited.deadline; + const childDeadline = options.deadline instanceof Date ? options.deadline.getTime() : options.deadline; + const signals = [inherited.signal, options.signal].filter((signal): signal is AbortSignal => signal !== undefined); + const opts: ToolScheduleOptions = { + ...inherited, ...options, + ...(signals.length ? { signal: AbortSignal.any(signals) } : {}), + ...(parentDeadline === undefined ? {} : { + deadline: childDeadline === undefined ? parentDeadline : Math.min(parentDeadline, childDeadline), + clock: inherited.clock, + }), + }; + const scheduled = tail.then(() => { + const error = admissionError(opts); + if (error !== undefined) throw error; + return this.withReservation(childMode, childOperation, opts); + }); + tail = scheduled.catch(() => undefined); + return scheduled; + }, + }); + try { + return await operation(bound); + } finally { + active = false; + await tail; + } + } +} diff --git a/src/framework/types.ts b/src/framework/types.ts index f7906df..b6869d2 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -5,34 +5,61 @@ * These types define the universal interface for AI-callable tools. */ -// ─── JSON Schema (subset used for tool parameters) ───────────────────────── +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { ExecutionMode, ReplayPolicy, ToolExecutionContext } from "./execution-types.js"; + +// ─── JSON Schema (draft-07 and explicit draft 2020-12) ──────────────────── export type JsonSchemaType = - | "string" - | "number" - | "integer" - | "boolean" - | "array" - | "object" - | "null"; + | "string" | "number" | "integer" | "boolean" | "array" | "object" | "null"; + +/** Boolean schemas are valid wherever a subschema is accepted. */ +export type JsonSchema = boolean | JsonSchemaProperty; export interface JsonSchemaProperty { - type?: JsonSchemaType | JsonSchemaType[]; + $schema?: string; + $vocabulary?: Record; + $id?: string; + $ref?: string; + $anchor?: string; + $dynamicAnchor?: string; + $dynamicRef?: string; + $defs?: Record; + definitions?: Record; + $comment?: string; + title?: string; description?: string; - /** - * Allowed values. JSON Schema permits any primitive here, not just - * strings — an integer field can legitimately constrain to enum: [1, 2, 3]. - */ + type?: JsonSchemaType | JsonSchemaType[]; enum?: unknown[]; const?: unknown; - items?: JsonSchemaProperty | JsonSchemaProperty[]; + default?: unknown; + examples?: unknown[]; + readOnly?: boolean; + writeOnly?: boolean; + deprecated?: boolean; + items?: JsonSchema | JsonSchema[]; + additionalItems?: JsonSchema; + prefixItems?: JsonSchema[]; + contains?: JsonSchema; + minContains?: number; + maxContains?: number; + unevaluatedItems?: JsonSchema; properties?: Record; + patternProperties?: Record; + propertyNames?: JsonSchema; required?: string[]; - default?: unknown; - additionalProperties?: boolean | JsonSchemaProperty; - anyOf?: JsonSchemaProperty[]; - oneOf?: JsonSchemaProperty[]; - allOf?: JsonSchemaProperty[]; + additionalProperties?: JsonSchema; + unevaluatedProperties?: JsonSchema; + dependencies?: Record; + dependentRequired?: Record; + dependentSchemas?: Record; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + not?: JsonSchema; + if?: JsonSchema; + then?: JsonSchema; + else?: JsonSchema; minLength?: number; maxLength?: number; pattern?: string; @@ -41,15 +68,17 @@ export interface JsonSchemaProperty { maximum?: number; exclusiveMinimum?: number; exclusiveMaximum?: number; + multipleOf?: number; minItems?: number; maxItems?: number; + uniqueItems?: boolean; + minProperties?: number; + maxProperties?: number; } -export interface InputSchema { +export interface InputSchema extends JsonSchemaProperty { type: "object"; properties: Record; - required?: string[]; - additionalProperties?: boolean | JsonSchemaProperty; } // ─── Capability Contract ────────────────────────────────────────────────── @@ -125,7 +154,7 @@ export interface ToolDefinition< inputSchema: InputSchema; /** The function that runs when the tool is called */ - handler: (params: TParams) => Promise>; + handler: (params: TParams, context?: ToolExecutionContext) => Promise>; /** Optional tags for grouping/filtering (e.g. "productivity", "education") */ tags?: string[]; @@ -138,6 +167,20 @@ export interface ToolDefinition< /** Optional execution semantics for consequential or delegated work. */ contract?: CapabilityContract; + /** Discovery hints only; these do not confer authority. */ + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + outputSchema?: InputSchema; + /** A trusted classifier; absent, invalid, or throwing means exclusive. */ + executionMode?: ExecutionMode | ((params: Readonly>) => ExecutionMode); + replayPolicy?: ReplayPolicy; + /** Provider discovery metadata. Never a grant or replay guarantee. */ + _meta?: Record; } // ─── Tool Test ───────────────────────────────────────────────────────────── @@ -172,6 +215,9 @@ export interface ToolResult { executionOutcome?: "succeeded" | "failed" | "unknown"; /** Optional human-friendly message (shown to the user by the AI) */ message?: string; + content?: CallToolResult["content"]; + structuredContent?: Record; + _meta?: Record; } // ─── Tool Example ─────────────────────────────────────────────────────────── diff --git a/src/framework/validate.ts b/src/framework/validate.ts index 29ed9d0..7c7e00d 100644 --- a/src/framework/validate.ts +++ b/src/framework/validate.ts @@ -1,227 +1,218 @@ -/** - * OpenFunction — Parameter Validator - * - * Validates tool inputs against the JSON Schema defined in inputSchema - * before the handler runs. Catches type mismatches, missing required - * fields, and invalid enum values with clear error messages. - * - * This runs automatically on every tool call — students don't need - * to add validation logic to their handlers. - */ - -import type { InputSchema, JsonSchemaProperty } from "./types.js"; +/** JSON Schema admission validation. Validators never mutate caller inputs. */ +import { createHash } from "node:crypto"; +import { Ajv, type ErrorObject, type Options, type ValidateFunction } from "ajv"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import formatsPlugin from "ajv-formats"; +import type { InputSchema } from "./types.js"; export interface ValidationError { field: string; message: string; } -/** - * Validate parameters against an inputSchema. - * Returns an array of errors (empty = valid). - */ -export function validateParams( - params: Record, - schema: InputSchema, -): ValidationError[] { - const errors: ValidationError[] = []; - const required = new Set(schema.required ?? []); - - // Check required fields - for (const field of required) { - if (params[field] === undefined) { - const prop = schema.properties[field]; - const desc = prop?.description ? ` (${prop.description})` : ""; - errors.push({ - field, - message: `Required parameter "${field}" is missing${desc}`, - }); - } +const options: Options = { + allErrors: true, + strictSchema: true, + strictNumbers: true, + strictTypes: false, + strictTuples: false, + strictRequired: false, + allowUnionTypes: true, + coerceTypes: false, + useDefaults: false, + removeAdditional: false, + validateFormats: true, + addUsedSchema: false, + ownProperties: true, + logger: false, +}; +const draft07 = new Ajv(options); +const draft2020 = new Ajv2020(options); +// Node's ESM/CJS interop exposes the plugin's callable default at runtime. +const addFormats = formatsPlugin as unknown as typeof import("ajv-formats").default; +addFormats(draft07); +addFormats(draft2020); +const cache = new Map(); +const CACHE_LIMIT = 128; + +/** Deterministic JSON encoding also rejects invalid schema values and cycles. */ +function canonicalJson(value: unknown, parents = new Set()): string { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return JSON.stringify(value); } - - // Check types and constraints for provided fields - for (const [key, value] of Object.entries(params)) { - if (value === undefined) continue; - - const prop = schema.properties[key]; - if (!prop) { - if (schema.additionalProperties === false) { - errors.push({ field: key, message: `Unexpected parameter "${key}"` }); - } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - errors.push(...validateField(key, value, schema.additionalProperties)); - } - continue; + if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); + if (typeof value !== "object" || value === null || parents.has(value)) { + throw new Error("Non-JSON value"); + } + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) { + throw new Error("Non-JSON object"); + } + parents.add(value); + try { + if (Array.isArray(value)) { + const entries = Array.from(value, (entry) => canonicalJson(entry, parents)); + return `[${entries.join(",")}]`; } - - const fieldErrors = validateField(key, value, prop); - errors.push(...fieldErrors); + return `{${Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson((value as Record)[key], parents)}`, + ).join(",")}}`; + } finally { + parents.delete(value); } - - return errors; } -/** - * Validate a single field against its schema property. - */ -function validateField( - name: string, - value: unknown, - schema: JsonSchemaProperty, -): ValidationError[] { - const errors: ValidationError[] = []; - - if (schema.anyOf && schema.anyOf.some((candidate) => validateField(name, value, candidate).length === 0)) { - return errors; - } - if (schema.anyOf) { - return [{ field: name, message: `"${name}" does not match any allowed schema` }]; - } +function dialectOf(schema: InputSchema): "draft07" | "2020-12" | undefined { + if (schema.$schema === undefined) return "draft07"; + const uri = schema.$schema.endsWith("#") ? schema.$schema.slice(0, -1) : schema.$schema; + if (uri === "http://json-schema.org/draft-07/schema") return "draft07"; + if (uri === "https://json-schema.org/draft/2020-12/schema") return "2020-12"; + return undefined; +} - if (schema.oneOf) { - const matches = schema.oneOf.filter((candidate) => validateField(name, value, candidate).length === 0); - if (matches.length === 1) return errors; - return [{ field: name, message: `"${name}" must match exactly one allowed schema` }]; +/** Check annotations Ajv recognizes but cannot enforce as application contracts. */ +function checkSchemaContracts(schema: unknown): void { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return; + const object = schema as Record; + if (object.$async !== undefined) throw new Error("Async schemas are unsupported"); + if (object.$vocabulary && typeof object.$vocabulary === "object") { + const supported = new Set([ + "https://json-schema.org/draft/2020-12/vocab/core", + "https://json-schema.org/draft/2020-12/vocab/applicator", + "https://json-schema.org/draft/2020-12/vocab/unevaluated", + "https://json-schema.org/draft/2020-12/vocab/validation", + "https://json-schema.org/draft/2020-12/vocab/meta-data", + "https://json-schema.org/draft/2020-12/vocab/format-annotation", + "https://json-schema.org/draft/2020-12/vocab/format-assertion", + "https://json-schema.org/draft/2020-12/vocab/content", + ]); + for (const [uri, required] of Object.entries(object.$vocabulary)) { + if (required === true && !supported.has(uri)) throw new Error("Unsupported required vocabulary"); + } } - - if (schema.allOf) { - for (const candidate of schema.allOf) { - errors.push(...validateField(name, value, candidate)); + // Descend through schema locations only; enum/const/default are data and + // may legitimately contain fields named $async, $ref, or $vocabulary. + for (const key of ["$defs", "definitions", "properties", "patternProperties", "dependentSchemas", "dependencies"]) { + const entries = object[key]; + if (entries !== null && typeof entries === "object") { + for (const child of Object.values(entries)) checkSchemaContracts(child); } - if (errors.length > 0) return errors; } - - if ("const" in schema && !Object.is(value, schema.const)) { - errors.push({ - field: name, - message: `"${name}" must equal ${JSON.stringify(schema.const)}`, - }); - return errors; + for (const key of ["items", "prefixItems", "anyOf", "oneOf", "allOf", "additionalItems", "contains", "unevaluatedItems", "propertyNames", "additionalProperties", "unevaluatedProperties", "not", "if", "then", "else"]) { + const child = object[key]; + if (Array.isArray(child)) child.forEach(checkSchemaContracts); + else checkSchemaContracts(child); } +} - // Type check - const actualType = getJsonSchemaType(value); - const allowedTypes = schema.type - ? (Array.isArray(schema.type) ? schema.type : [schema.type]) - : []; - if (allowedTypes.length > 0 && !allowedTypes.includes(actualType as never)) { - // Allow integer where number is expected - if (allowedTypes.includes("number") && actualType === "integer") { - // Fine — integers are valid numbers - } else if (allowedTypes.includes("integer") && actualType === "number") { - // Check if it's actually an integer - if (!Number.isInteger(value)) { - errors.push({ - field: name, - message: `"${name}" must be an integer, got ${value}`, - }); - } - } else { - errors.push({ - field: name, - message: `"${name}" must be ${allowedTypes.join(" or ")}, got ${actualType} (${JSON.stringify(value)})`, - }); - return errors; // Skip further checks if type is wrong - } - } +function pointerTokens(pointer: string): string[] { + return pointer.split("/").slice(1).map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")); +} - // Enum check — JSON Schema enums can hold any primitive (number, - // boolean, string), not just strings. Avoid coercing here. - if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) { - errors.push({ - field: name, - message: `"${name}" must be one of: ${schema.enum.join(", ")} — got "${value}"`, - }); +/** Identifier paths remain readable; other property names are escaped literals. */ +function fieldPath(tokens: string[], params: unknown): string { + let current = params; + let field = ""; + for (const token of tokens) { + if (Array.isArray(current) && /^(0|[1-9][0-9]*)$/.test(token)) field += `[${token}]`; + else if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(token)) field += `${field ? "." : ""}${token}`; + else field += `[${JSON.stringify(token)}]`; + current = current !== null && typeof current === "object" + ? (current as Record)[token] : undefined; } + return field || "$"; +} - // Array items check - if (allowedTypes.includes("array") && Array.isArray(value) && schema.items && !Array.isArray(schema.items)) { - for (let i = 0; i < value.length; i++) { - const itemErrors = validateField(`${name}[${i}]`, value[i], schema.items); - errors.push(...itemErrors); - } +function schemaAt(schema: InputSchema, pointer: string): Record | undefined { + let current: unknown = schema; + for (const token of pointerTokens(pointer.replace(/^#/, ""))) { + current = current !== null && typeof current === "object" + ? (current as Record)[token] : undefined; } + return current !== null && typeof current === "object" ? current as Record : undefined; +} - // Nested object check - if ( - allowedTypes.includes("object") && - typeof value === "object" && - value !== null && - schema.properties - ) { - const nestedRequired = new Set(schema.required ?? []); - const obj = value as Record; - - for (const field of nestedRequired) { - if (obj[field] === undefined) { - errors.push({ - field: `${name}.${field}`, - message: `Required field "${name}.${field}" is missing`, - }); - } +function mapError(error: ErrorObject, params: unknown, schema: InputSchema): ValidationError { + const tokens = pointerTokens(error.instancePath); + if (error.keyword === "required") tokens.push(String(error.params.missingProperty)); + if (error.keyword === "additionalProperties") tokens.push(String(error.params.additionalProperty)); + if (error.keyword === "unevaluatedProperties") tokens.push(String(error.params.unevaluatedProperty)); + const field = fieldPath(tokens, params); + const quoted = JSON.stringify(field); + let message: string; + switch (error.keyword) { + case "required": { + const parent = schemaAt(schema, error.schemaPath.slice(0, -"/required".length)); + const properties = parent?.properties as Record | undefined; + const property = properties?.[String(error.params.missingProperty)] as Record | undefined; + const description = typeof property?.description === "string" ? ` (${property.description})` : ""; + message = `Required parameter ${quoted} is missing${description}`; + break; } - - for (const [key, val] of Object.entries(obj)) { - if (val === undefined) continue; - const propSchema = schema.properties[key]; - if (propSchema) { - const fieldErrors = validateField(`${name}.${key}`, val, propSchema); - errors.push(...fieldErrors); - } else if (schema.additionalProperties === false) { - errors.push({ field: `${name}.${key}`, message: `Unexpected field "${name}.${key}"` }); - } else if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - errors.push(...validateField(`${name}.${key}`, val, schema.additionalProperties)); - } + case "type": + message = `${quoted} must be ${error.params.type === "integer" ? "an integer" : error.params.type}`; + break; + case "enum": { + const allowed = error.params.allowedValues as unknown[]; + message = `${quoted} must be one of: ${allowed.map((value) => typeof value === "string" ? value : JSON.stringify(value)).join(", ")}`; + break; } + case "additionalProperties": case "unevaluatedProperties": + message = `Unexpected parameter ${quoted}`; break; + case "const": message = `${quoted} must equal its declared constant`; break; + case "anyOf": message = `${quoted} does not match any allowed schema`; break; + case "oneOf": message = `${quoted} must match exactly one allowed schema`; break; + default: + // Ajv's message describes the constraint, never the failing instance. + message = `${quoted} ${error.message ?? "does not satisfy the schema"}`; } + return { field, message }; +} - if (typeof value === "string") { - if (schema.minLength !== undefined && value.length < schema.minLength) { - errors.push({ field: name, message: `"${name}" must contain at least ${schema.minLength} characters` }); - } - if (schema.maxLength !== undefined && value.length > schema.maxLength) { - errors.push({ field: name, message: `"${name}" must contain at most ${schema.maxLength} characters` }); - } - if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) { - errors.push({ field: name, message: `"${name}" must match ${schema.pattern}` }); +/** Returns all validation errors; invalid/unsupported schemas fail closed. */ +export function validateParams(params: Record, schema: InputSchema): ValidationError[] { + let validate: ValidateFunction; + try { + const dialect = dialectOf(schema); + if (!dialect) return [{ field: "$schema", message: "Unsupported JSON Schema dialect" }]; + const canonical = canonicalJson(schema); + checkSchemaContracts(schema); + const key = `${dialect}:${createHash("sha256").update(canonical).digest("hex")}`; + const cached = cache.get(key); + if (cached) { + cache.delete(key); + cache.set(key, cached); + validate = cached; + } else { + // Compile a detached canonical schema: Ajv identity caches cannot retain + // a stale validator if a caller subsequently edits the original object. + const ajv = dialect === "draft07" ? draft07 : draft2020; + const detached = JSON.parse(canonical); + validate = ajv.compile(detached); + ajv.removeSchema(detached); + if ("$async" in validate && validate.$async) throw new Error("Asynchronous validation is unsupported"); + cache.set(key, validate); + if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!); } + } catch { + return [{ field: "$schema", message: "Invalid JSON Schema or unsupported keyword, format, or reference" }]; } - - if (typeof value === "number") { - if (schema.minimum !== undefined && value < schema.minimum) errors.push({ field: name, message: `"${name}" must be at least ${schema.minimum}` }); - if (schema.maximum !== undefined && value > schema.maximum) errors.push({ field: name, message: `"${name}" must be at most ${schema.maximum}` }); - if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) errors.push({ field: name, message: `"${name}" must be greater than ${schema.exclusiveMinimum}` }); - if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) errors.push({ field: name, message: `"${name}" must be less than ${schema.exclusiveMaximum}` }); - } - - if (Array.isArray(value)) { - if (schema.minItems !== undefined && value.length < schema.minItems) errors.push({ field: name, message: `"${name}" must contain at least ${schema.minItems} items` }); - if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push({ field: name, message: `"${name}" must contain at most ${schema.maxItems} items` }); + try { + canonicalJson(params); + } catch { + return [{ field: "$", message: "Parameters must contain only finite JSON values" }]; } - - return errors; -} - -/** - * Map a JS value to its JSON Schema type name. - */ -function getJsonSchemaType(value: unknown): string { - if (Array.isArray(value)) return "array"; - if (value === null) return "null"; - const t = typeof value; - if (t === "number") return Number.isInteger(value) ? "integer" : "number"; - return t; // "string", "boolean", "object" + if (validate(params)) return []; + const errors = validate.errors ?? []; + // Report a failed union once instead of printing each alternative's errors. + // Sibling constraints retain their own errors and are never short-circuited. + const unions = errors.filter((error) => error.keyword === "anyOf" || error.keyword === "oneOf"); + return errors.filter((error) => !unions.some((union) => + error !== union && error.schemaPath.startsWith(`${union.schemaPath}/`), + )).map((error) => mapError(error, params, schema)); } -/** - * Format validation errors into a human-readable string. - */ -export function formatValidationErrors( - toolName: string, - errors: ValidationError[], -): string { - const lines = [`Parameter validation failed for "${toolName}":`]; - for (const err of errors) { - lines.push(` - ${err.message}`); - } - return lines.join("\n"); +export function formatValidationErrors(toolName: string, errors: ValidationError[]): string { + return [`Parameter validation failed for ${JSON.stringify(toolName)}:`, + ...errors.map((error) => ` - ${error.message}`)].join("\n"); } diff --git a/src/framework/work/controller.ts b/src/framework/work/controller.ts new file mode 100644 index 0000000..6e86f67 --- /dev/null +++ b/src/framework/work/controller.ts @@ -0,0 +1,473 @@ +/** Own one explicitly selected bounded attempt. No model loop or implicit recovery effects. */ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { join, relative, resolve, isAbsolute } from "node:path"; +import { FileArtifactStore } from "../artifacts.js"; +import { ExecutionJournal } from "../execution-journal.js"; +import { capabilityDigest } from "../executor.js"; +import { digestJson, snapshotJson } from "../session.js"; +import { acquireJournalOwnership, initializePrivateRoot, JournalInvariantError, readPrivateFile, writePrivateFileAtomic, type JournalFileLock } from "../journal-file.js"; +import { WorkAttemptJournal } from "./journal.js"; +import { fingerprintSubject, pathMatches, scopedPath } from "./verification.js"; +import type { BackendReceipt, LeaseRef, OperationKind, OperationRef, ResolvedWorkGrant, VerificationEvidence, WorkAuthorizationBinding, WorkControllerOptions, WorkPreview, WorkSnapshot, WorkState, WorkStepExecution } from "./types.js"; +import type { ToolExecution, ExecutionPolicy, ToolExecutionContext, ToolExecutionReceipt, ExecutionJournalSink } from "../execution-types.js"; +import type { ToolRegistry } from "../registry.js"; + +class Mutex { + private tail:Promise=Promise.resolve(); + async run(callback:()=>Promise):Promise{const before=this.tail;let release!:()=>void;this.tail=new Promise(resolve=>release=resolve);await before;try{return await callback();}finally{release();}} +} +interface PendingOperation {ref:OperationRef;payload:Record;status:"started"|"settled";backendReceipt?:BackendReceipt;} +interface StepIntent {stepKey:string;inputDigest:string;capabilityDigest:string;admittedPaths:string[];reconciliationMetadata?:Readonly>;oracleDigest?:string;} +interface PersistedAttempt { + attemptId:string;workItemId:string;executionFileIdentity?:{dev:string;ino:string};binding:WorkAuthorizationBinding;preview:WorkPreview;state:WorkState; + startedAt:number;mutationEpoch:number;actions:number;outputBytes:number;accountedOutputs:Record;lease?:LeaseRef; + operations:Record;inflightSteps:Record;unknownSteps:Record;oracleRuns:Record;effectResolutions:Record; + evidence:Record;verificationRuns:Record;outputArtifacts:Record;unknownExecutions:string[];renewalFailed:boolean; +} +export class WorkAttemptController { + private readonly admissions=new Mutex();private readonly lifecycle=new Mutex(); + private state?:PersistedAttempt;private journal?:WorkAttemptJournal;private executions?:ExecutionJournal; + private ownership?:JournalFileLock;private controllerOwnership?:JournalFileLock;private timer?:ReturnType;private stopping=false; + private readonly lifetime=new AbortController();private inflight=new Set>(); + private artifacts!:FileArtifactStore;private readonly registry:ToolRegistry; + private readonly root:string;private readonly activePath:string; + constructor(private readonly options:WorkControllerOptions){ + this.root=initializePrivateRoot(options.journalRoot);this.activePath=join(this.root,"active-attempt.json"); + + this.registry=options.registry.project({names:Object.values(options.steps).map(step=>step.toolName)},this.policy()); + if(existsSync(this.activePath)) { + const active=JSON.parse(readPrivateFile(this.activePath,64*1024).toString("utf8")) as {attemptId:string;workItemId:string;scopeDigest:string;executionFileIdentity?:{dev:string;ino:string}}; + if(!active.attemptId || !active.workItemId || !active.scopeDigest)throw new JournalInvariantError("Malformed active work reference"); + this.attach(active.attemptId,active.scopeDigest,true,active.executionFileIdentity); + const events=this.journal!.read();const last=events[events.length-1]; + if(last){this.state=structuredClone(last.data.state) as PersistedAttempt;this.validateRecovered();}else throw new JournalInvariantError("Active attempt journal has no prepared event"); + } + } + private assertAdmissionContext(context:ToolExecutionContext={}):void { + if(this.stopping || this.lifetime.signal.aborted || context.signal?.aborted || context.deadline !== undefined && (!Number.isFinite(context.deadline) || context.deadline <= this.now()))throw new JournalInvariantError("Work admission cancelled or deadline elapsed"); + } + private requestSignal(context:ToolExecutionContext={}):AbortSignal {return context.signal ? AbortSignal.any([context.signal,this.lifetime.signal]) : this.lifetime.signal;} + private now():number{return this.options.clock?.() ?? Date.now();} + private attach(id:string,scope:string,recovery=false,expectedFileIdentity?:{dev:string;ino:string}):void { + this.artifacts=this.options.artifactStore ?? new FileArtifactStore({rootDir:join(this.options.artifactRoot,id)}); + this.journal=new WorkAttemptJournal({rootDir:this.root,attemptId:id,scopeDigest:scope,requireExisting:recovery}); + this.executions=new ExecutionJournal({rootDir:join(this.root,"executions"),streamId:id,scopeDigest:scope,requireExisting:recovery,expectedFileIdentity}); + } + private validateRecovered():void { + const state=this.state!;state.verificationRuns ??= {};state.outputArtifacts ??= {};state.unknownSteps ??= {};state.effectResolutions ??= {};state.accountedOutputs ??= {};state.oracleRuns ??= {}; + if(state.attemptId !== this.journal!.read()[0]!.streamId || state.binding.scopeDigest !== this.journal!.read()[0]!.scopeDigest || state.binding.backendId !== state.preview.binding.backendId || state.binding.workItemId !== state.workItemId)throw new JournalInvariantError("Recovered attempt identity mismatch"); + if(Object.keys(state.inflightSteps).length || Object.keys(state.verificationRuns).length || Object.values(state.operations).some(operation=>operation.status === "started"))state.state="reconciliation_required"; + // A lease from the former process is evidence, never fresh ownership authority. + } + private persist(type:Parameters[0]["type"],data:Record={},refs:{executionId?:string;receiptRef?:string}={}):void { + if(!this.state || !this.journal)throw new JournalInvariantError("No work attempt"); + this.journal.record({type,data:{...data,state:snapshotJson(this.state)},...refs}); + } + private requireState():PersistedAttempt {if(!this.state)throw new JournalInvariantError("No selected work attempt");return this.state;} + private ensureOwner():void { + if(this.ownership)return; + this.controllerOwnership=acquireJournalOwnership(this.root,"active-controller"); + try{this.ownership=this.journal!.acquireOwnership();}catch(error){this.controllerOwnership.release();this.controllerOwnership=undefined;throw error;} + } + private releaseOwnership():void {this.ownership?.release();this.ownership=undefined;this.controllerOwnership?.release();this.controllerOwnership=undefined;} + private async grant(purpose:"execution"|"completion"="execution",cleanup=false):Promise { + const state=this.requireState();const grant=await this.options.grantResolver.resolve(state.binding,purpose); + if(!grant || grant.expiresAt <= this.now() || purpose === "completion" && !grant.completion)throw new JournalInvariantError(`Current ${purpose} grant missing or expired`); + if(!cleanup && state.outputBytes >= grant.limits.maxOutputBytes)throw new JournalInvariantError("Attempt output budget is exhausted; fresh stronger authorization required"); + if(!cleanup && this.now()-state.startedAt >= grant.limits.maxDurationMs)throw new JournalInvariantError("Attempt duration limit reached");return grant; + } + private accountOutput(executionId:string,bytes:number):void { + const state=this.requireState();if(state.accountedOutputs[executionId])return; + if(!Number.isSafeInteger(bytes) || bytes < 0)throw new JournalInvariantError("Invalid execution output accounting"); + state.outputBytes+=bytes;state.accountedOutputs[executionId]={bytes,recorded:false}; + } + private async recordOutput(executionId:string):Promise { + const state=this.requireState();const entry=state.accountedOutputs[executionId];if(!entry || entry.recorded)return; + await this.options.grantResolver.reserveAttempt?.(state.binding,state.attemptId); + await this.options.grantResolver.recordOutput?.(state.binding,state.attemptId,entry.bytes,executionId); + entry.recorded=true;this.persist("reconciled",{outputAccountedExecutionId:executionId,bytes:entry.bytes}); + } + private policy():ExecutionPolicy {return {authorize:async ({toolSnapshot,params})=>{ + try { + const state=this.requireState(); + const accepting=()=>!this.stopping && ["running","verifying"].includes(state.state) && !state.unknownExecutions.length && !state.renewalFailed; + if(!accepting())return {decision:"deny",reasonCodes:["work_reconciliation_required"]}; + // Queueing and journal I/O can outlive the ownership observed at admission. + await this.fresh(["running"]); + const grant=await this.grant(); + if(!accepting() || !state.lease || state.lease.expiresAt <= this.now())return {decision:"deny",reasonCodes:["inactive_work_lease"]}; + const step=Object.entries(this.options.steps).find(([,binding])=>binding.toolName === toolSnapshot.name); + if(!step || grant.stepBindings[step[0]]?.capabilityDigest !== capabilityDigest(toolSnapshot) || grant.stepBindings[step[0]]?.toolName !== toolSnapshot.name || !grant.actions.includes(step[1].action) || !state.preview.snapshot.contract.actions.includes(step[1].action))return {decision:"deny",reasonCodes:["unbound_work_step"]}; + step[1].validateParams?.(params);this.validatePaths(step[1].paths?.(params) ?? [],grant);return {decision:"allow",authorityGrantRefs:[grant.id],expiresAt:grant.expiresAt}; + }catch{return {decision:"deny",reasonCodes:["work_authority_unavailable"]};} + }};} + private validatePaths(paths:readonly string[],grant:ResolvedWorkGrant):void { + for(const path of paths){const absolute=scopedPath(this.options.workspaceRoot,path,true);const rel=relative(resolve(this.options.workspaceRoot),absolute).replaceAll("\\","/"); + const allowedRoot=grant.roots.some(root=>{const part=relative(resolve(root),absolute);return part !== ".." && !part.startsWith("../") && !isAbsolute(part);}); + const contract=this.requireState().preview.snapshot.contract; + const contractRoot=contract.roots.some(root=>{const part=relative(resolve(root),absolute);return part !== ".." && !part.startsWith("../") && !isAbsolute(part);}); + if(!allowedRoot || !contractRoot || !pathMatches(rel,grant.include) || pathMatches(rel,grant.exclude) || !pathMatches(rel,contract.include) || pathMatches(rel,contract.exclude))throw new JournalInvariantError("Step path outside granted scope"); + } + } + private binding(snapshot:WorkSnapshot,plan:WorkPreview["plan"]):WorkAuthorizationBinding { + return {backendId:snapshot.backendId,workItemId:snapshot.workItemId,workspaceId:snapshot.workspaceId,...(snapshot.projectId ? {projectId:snapshot.projectId}:{}),contractDigest:snapshot.contractDigest,revision:snapshot.revision,planId:plan.id,planVersion:plan.version,planDigest:plan.digest,scopeDigest:digestJson({backendId:snapshot.backendId,workspaceId:snapshot.workspaceId,projectId:snapshot.projectId,workspaceRoot:resolve(this.options.workspaceRoot),contractDigest:snapshot.contractDigest})}; + } + async prepare(workItemId:string,context:ToolExecutionContext={}):Promise { + const [snapshot,plan]=await Promise.all([this.options.backend.inspect(workItemId,context.signal),this.options.backend.getVerificationPlan(workItemId,context.signal)]); + if(snapshot.workItemId !== workItemId || snapshot.contractDigest !== digestJson(snapshot.contract) || plan.state !== "active" || plan.digest !== digestJson({id:plan.id,version:plan.version,state:plan.state,steps:plan.steps}))throw new JournalInvariantError("Selected work contract or active plan invalid"); + const steps=Object.fromEntries(Object.entries(this.options.steps).map(([key,step])=>[key,{toolName:step.toolName,action:step.action,capabilityDigest:step.capabilityDigest}])); + const binding=this.binding(snapshot,plan);const createdAt=this.now(); + return snapshotJson({binding,snapshot,plan,steps,createdAt,digest:digestJson({binding,snapshot,plan,steps,createdAt})}) as WorkPreview; + } + private validateLease(lease:LeaseRef,requireLive=true):void { + const state=this.requireState(); + if(!lease || typeof lease.id !== "string" || !lease.id || lease.backendId !== state.binding.backendId || lease.workItemId !== state.workItemId || lease.owner !== this.options.claimOwner || !Number.isFinite(lease.expiresAt) || requireLive && lease.expiresAt <= this.now())throw new JournalInvariantError("Lease descriptor identity, owner or expiry mismatch"); + } + private validateBackendReceipt(receipt:BackendReceipt,operation:OperationRef):void { + const state=this.requireState(); + if(!receipt || typeof receipt.id !== "string" || !receipt.id || receipt.kind !== operation.kind || receipt.operationId !== (operation.backendKey ?? operation.id) || receipt.payloadDigest !== operation.payloadDigest || receipt.workItemId !== state.workItemId)throw new JournalInvariantError("Backend receipt operation/work binding mismatch"); + const expected={open:["claimed"],start:["running"],heartbeat:["claimed","running"],evidence:["running"],complete:["completed"],review:["needs_review"],block:["blocked"],fail:["failed"],release:["released"]}[operation.kind]; + if(!expected.includes(receipt.state))throw new JournalInvariantError("Backend receipt lifecycle mismatch"); + if(receipt.lease)this.validateLease(receipt.lease,false); + } + private async fresh(allowedStates:readonly string[]=["running","completed"]):Promise<{snapshot:WorkSnapshot;plan:WorkPreview["plan"]}> { + const state=this.requireState();const preview=await this.prepare(state.workItemId); + if(digestJson(preview.binding) !== digestJson(state.binding))throw new JournalInvariantError("Work contract, plan or scope changed"); + if(!allowedStates.includes(preview.snapshot.state))throw new JournalInvariantError("Selected backend lifecycle is incompatible with dispatch"); + if(state.lease)this.validateLease(state.lease,false); + if(preview.snapshot.lease)this.validateLease(preview.snapshot.lease); + if(!state.lease || !preview.snapshot.lease || state.lease.id !== preview.snapshot.lease.id || state.lease.owner !== preview.snapshot.lease.owner || preview.snapshot.lease.expiresAt <= this.now())throw new JournalInvariantError("Fresh backend lease ownership unavailable"); + state.lease=preview.snapshot.lease;return {snapshot:preview.snapshot,plan:preview.plan}; + } + private operation(kind:OperationKind,payload:Record,id:string=randomUUID()):OperationRef { + const caps=this.options.backend.capabilities; + return {id,kind,payloadDigest:digestJson(payload),capabilityVersion:caps.version,...(caps.keyedOperations.includes(kind) ? {backendKey:id}:{}),...(caps.deduplicationWindowMs ? {deduplicationDeadline:this.now()+caps.deduplicationWindowMs}: {})}; + } + private async backendOperation(kind:OperationKind,payload:Record,dispatch:(ref:OperationRef)=>Promise,id?:string,backendKey?:string,context:ToolExecutionContext={}):Promise { + this.assertAdmissionContext(context);const state=this.requireState();const ref=this.operation(kind,payload,id);if(backendKey){if(!this.options.backend.capabilities.keyedOperations.includes(kind))throw new JournalInvariantError("Backend keyed recovery unsupported");ref.backendKey=backendKey;}state.operations[ref.id]={ref,payload,status:"started"};this.persist("operation_started",{operationId:ref.id,kind,capabilityVersion:ref.capabilityVersion,payloadDigest:ref.payloadDigest}); + try{const receipt=await dispatch(ref);this.validateBackendReceipt(receipt,ref);state.operations[ref.id]!.status="settled";state.operations[ref.id]!.backendReceipt=receipt;if(receipt.lease)state.lease=receipt.lease;this.persist("operation_settled",{operationId:ref.id,backendReceiptRef:receipt.id});return receipt;} + catch(error){state.state="reconciliation_required";this.stopTimer();this.persist("reconciled",{operationId:ref.id,disposition:"unknown"});throw error;} + } + async start(workItemId:string,context:ToolExecutionContext={}):Promise { + return this.admissions.run(async()=>{ + if(this.stopping)throw new JournalInvariantError("Controller shut down"); + if(this.state && !["completed","released","failed"].includes(this.state.state))throw new JournalInvariantError("Existing attempt must be reconciled or finished"); + const preview=await this.prepare(workItemId,context);this.assertAdmissionContext(context);if(!preview.snapshot.claimable || !preview.snapshot.dependenciesSatisfied)throw new JournalInvariantError("Selected work is not claimable"); + const authorized=await this.options.grantResolver.resolve(preview.binding,"execution");this.assertAdmissionContext(context); + if(!authorized || authorized.expiresAt <= this.now() || authorized.limits.maxAttempts < 1)throw new JournalInvariantError("Current execution grant missing, expired or exhausted"); + const priorState=this.state;const attemptId=this.options.attemptId ?? randomUUID();this.releaseOwnership();this.attach(attemptId,preview.binding.scopeDigest);this.ensureOwner(); + this.state={attemptId,workItemId,executionFileIdentity:this.executions!.fileIdentity(),binding:preview.binding,preview,state:"prepared",startedAt:this.now(),mutationEpoch:0,actions:0,outputBytes:0,accountedOutputs:{},operations:{},inflightSteps:{},unknownSteps:{},oracleRuns:{},effectResolutions:{},evidence:{},verificationRuns:{},outputArtifacts:{},unknownExecutions:[],renewalFailed:false}; + let grant:ResolvedWorkGrant; + try{grant=await this.grant();await this.options.grantResolver.reserveAttempt?.(preview.binding,attemptId);}catch(error){this.releaseOwnership();this.state=priorState;throw error;} + this.persist("prepared",{grantRef:grant.id});writePrivateFileAtomic(this.activePath,Buffer.from(JSON.stringify({attemptId,workItemId,scopeDigest:preview.binding.scopeDigest,executionFileIdentity:this.state.executionFileIdentity})),true); + this.state.state="claiming"; + return this.lifecycle.run(async()=>{ + const id=randomUUID();const payload={workItemId,claimOwner:this.options.claimOwner,leaseSeconds:this.options.leaseSeconds ?? 120,idempotencyKey:id}; + const receipt=await this.backendOperation("open",payload,async()=>{ + const lease=await this.options.backend.open(payload,this.requestSignal(context));this.validateLease(lease);return {id:lease.id,operationId:id,kind:"open",payloadDigest:digestJson(payload),workItemId,state:"claimed",lease}; + },id,undefined,context); + this.state!.lease=receipt.lease;await this.fresh(["claimed"]);await this.grant(); + await this.backendOperation("start",{leaseId:receipt.lease!.id,action:"start",payload:{}},ref=>this.options.backend.transition(receipt.lease!,"start",{},ref.backendKey ?? ref.id,this.requestSignal(context)),undefined,undefined,context); + this.state!.state="running";this.persist("running");this.scheduleRenewal();return this.safeStatus(); + }); + }); + } + async step(stepKey:string,rawParams:Record,context:ToolExecutionContext={}):Promise { + const params=snapshotJson(rawParams,"work step parameters") as Record;this.assertAdmissionContext(context); + const admitted=await this.admissions.run(async()=>{ + const state=this.requireState();this.ensureOwner(); + if(this.stopping || !["running","verifying"].includes(state.state) || Object.keys(state.inflightSteps).length && state.state === "verifying")throw new JournalInvariantError("Attempt is not accepting work steps"); + const sourceStep=this.options.steps[stepKey];const step=sourceStep ? Object.freeze({...sourceStep}):undefined;if(state.state === "verifying" && this.inflight.size && step?.action !== "read")throw new JournalInvariantError("Verification freezes work mutations");if(!step)throw new JournalInvariantError("Unregistered work step"); + const grant=await this.grant();await this.fresh(); + if(state.renewalFailed || state.lease!.expiresAt-this.now() <= (this.options.renewalSafetyMs ?? 250) || !grant.actions.includes(step.action) || !state.preview.snapshot.contract.actions.includes(step.action) || grant.stepBindings[stepKey]?.toolName !== step.toolName || grant.stepBindings[stepKey]?.capabilityDigest !== step.capabilityDigest)throw new JournalInvariantError("Step authority or lease unavailable"); + const tool=this.registry.get(step.toolName);if(!tool || capabilityDigest(tool) !== step.capabilityDigest)throw new JournalInvariantError("Step capability changed"); + step.validateParams?.(snapshotJson(params));this.validatePaths(step.paths?.(params) ?? [],grant); + if(state.actions >= grant.limits.maxActions)throw new JournalInvariantError("Action limit reached"); + const admittedPaths=step.paths?.(params) ?? []; + const reconciliationMetadata=step.prepareReconciliation ? snapshotJson(step.prepareReconciliation(params,this.options.workspaceRoot),"effect readback metadata"):undefined; + const oracleDigest=step.prepareReconciliation && step.reconcileEffect && step.reconciliationSpecDigest ? digestJson({specDigest:step.reconciliationSpecDigest,prepare:Function.prototype.toString.call(step.prepareReconciliation),readback:Function.prototype.toString.call(step.reconcileEffect),capabilityDigest:step.capabilityDigest}):undefined; + await this.options.grantResolver.reserveAttempt?.(state.binding,state.attemptId); + await this.options.grantResolver.reserveAction?.(state.binding,state.attemptId); + this.assertAdmissionContext(context);state.actions++;if(step.action !== "read"){state.mutationEpoch++;state.evidence={};state.state="running";} + const id=randomUUID();state.inflightSteps[id]={stepKey,inputDigest:digestJson(params),capabilityDigest:step.capabilityDigest,admittedPaths,...(reconciliationMetadata ? {reconciliationMetadata}:{}),...(oracleDigest ? {oracleDigest}: {})}; + this.persist("reconciled",{stepIntentId:id,stepKey,inputDigest:digestJson(params),mutationEpoch:state.mutationEpoch}); + return {id,step,grant,context:{...context,callId:id,runId:state.attemptId,expectedCapabilityDigest:step.capabilityDigest,signal:context.signal ? AbortSignal.any([context.signal,this.lifetime.signal]) : this.lifetime.signal,deadline:Math.min(context.deadline ?? Infinity,grant.expiresAt,state.startedAt+grant.limits.maxDurationMs)} satisfies ToolExecutionContext}; + }); + const publishUncertainty=async (receipt:ToolExecutionReceipt)=>{ + // Publish uncertainty while the executor still owns its reservation. + // Otherwise its next queued effect can start before step() sees the result. + await this.admissions.run(async()=>{ + const state=this.requireState();state.state="reconciliation_required"; + if(!state.unknownExecutions.includes(receipt.executionId))state.unknownExecutions.push(receipt.executionId); + state.unknownSteps[receipt.executionId]=state.inflightSteps[admitted.id]!; + this.persist("reconciled",{disposition:"execution_uncertain",stepIntentId:admitted.id},{executionId:receipt.executionId,receiptRef:receipt.executionId}); + }); + }; + const lifecycle:ExecutionJournalSink={ + record:event=>this.executions!.record(event), + onSettled:async execution=>{if(execution.outcome === "unknown")await publishUncertainty(execution.receipt);}, + }; + const call=this.registry.executeDetailed(admitted.step.toolName,params,admitted.context,lifecycle);this.inflight.add(call); + let execution:ToolExecution; + try{execution=await call;}finally{this.inflight.delete(call);} + await this.admissions.run(async()=>{ + const state=this.requireState();const intent=state.inflightSteps[admitted.id]!;delete state.inflightSteps[admitted.id]; + const bytes=Buffer.byteLength(execution.modelContent);this.accountOutput(execution.receipt.executionId,bytes); + if(execution.outcome === "unknown"){state.state="reconciliation_required";if(!state.unknownExecutions.includes(execution.receipt.executionId))state.unknownExecutions.push(execution.receipt.executionId);state.unknownSteps[execution.receipt.executionId]=intent;} + this.persist("step",{stepIntentId:admitted.id,stepKey,executionOutcome:execution.outcome}, {executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId}); + try{ + await this.recordOutput(execution.receipt.executionId); + if(state.outputBytes > admitted.grant.limits.maxOutputBytes)throw new JournalInvariantError("Work output limit reached"); + }catch(error){state.state="reconciliation_required";this.persist("reconciled",{disposition:"output_accounting_failed",executionRef:execution.receipt.executionId});throw error;} + }); + if(Buffer.byteLength(JSON.stringify(execution))>30*1024){ + try{const artifact=this.artifacts.put(execution.modelContent,{mediaType:"application/json"});this.requireState().outputArtifacts[execution.receipt.executionId]=artifact.id;this.persist("reconciled",{outputArtifactRef:artifact.id,executionRef:execution.receipt.executionId});return {...execution,artifactProjection:{artifact,executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId}};} + catch(error){this.requireState().state="reconciliation_required";this.persist("reconciled",{disposition:"output_artifact_failed",executionRef:execution.receipt.executionId});throw error;} + } + + return execution; + } + private async verificationAdmission(stepId:string):Promise<{binding:WorkControllerOptions["verificationBindings"][number];plan:WorkPreview["plan"]}> { + return this.admissions.run(async()=>{ + const state=this.requireState();this.ensureOwner();if(this.stopping || !["running","verifying"].includes(state.state) || Object.keys(state.inflightSteps).length || state.unknownExecutions.length || this.inflight.size)throw new JournalInvariantError("Work effects must drain before verification"); + await this.grant();const {plan}=await this.fresh();const step=plan.steps.find(step=>step.id === stepId);const binding=this.options.verificationBindings.find(binding=>binding.stepId === stepId && binding.specDigest === step?.specDigest && binding.environment === step.environment); + if(!step || !binding || ["human","external"].includes(step.kind))throw new JournalInvariantError("Verification method unsupported or unbound"); + state.state="verifying";this.persist("reconciled",{verificationIntent:stepId});return {binding,plan}; + }); + } + async verify(stepId:string,context:ToolExecutionContext={}):Promise { + this.assertAdmissionContext(context); + const {binding,plan}=await this.verificationAdmission(stepId);const state=this.requireState(); + const runId=randomUUID();const evidenceId=randomUUID(); + const verificationDeadline=await this.admissions.run(async()=>{ + const grant=await this.grant();if(!grant.actions.includes("test") || !state.preview.snapshot.contract.actions.includes("test") || state.actions >= grant.limits.maxActions)throw new JournalInvariantError("Verification test authority or action limit unavailable"); + await this.options.grantResolver.reserveAttempt?.(state.binding,state.attemptId);await this.options.grantResolver.reserveAction?.(state.binding,state.attemptId);state.actions++; + state.verificationRuns[runId]={stepId,evidenceId};this.persist("reconciled",{verificationRunId:runId,stepId,evidenceId}); + return Math.min(context.deadline ?? Infinity,this.now()+plan.steps.find(step=>step.id === stepId)!.timeoutMs,grant.expiresAt,state.startedAt+grant.limits.maxDurationMs); + }); + const privateRegistry=this.options.registry.project({names:[]},{authorize:async()=>{ + try{const grant=await this.grant();if(this.stopping || state.renewalFailed || !grant.actions.includes("test") || !state.lease || state.lease.expiresAt <= this.now())return {decision:"deny",reasonCodes:["verification_authority_unavailable"]};return {decision:"allow",authorityGrantRefs:[grant.id],expiresAt:grant.expiresAt};}catch{return {decision:"deny",reasonCodes:["verification_grant_unavailable"]};} + }}); + const tool={name:`_of_verify_${stepId.replace(/[^a-zA-Z0-9_]/g,"_")}`,description:"Run the exact configured active verification binding",inputSchema:{type:"object" as const,properties:{},additionalProperties:false},contract:{version:"1",useWhen:[],doNotUseWhen:[],sideEffects:["Execute separately authorized verification command"],commitmentClass:"C1" as const,idempotency:"non_idempotent" as const,requiresAuthority:true,mutation:true,bounds:{specDigest:binding.specDigest,environment:binding.environment}},handler:async(_params:Record,boundContext?:ToolExecutionContext)=>{ + const before=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch);const startedAt=this.now(); + const result=await binding.run({workspaceRoot:this.options.workspaceRoot,signal:boundContext?.signal ?? (context.signal ? AbortSignal.any([context.signal,this.lifetime.signal]) : this.lifetime.signal)}); + const after=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch);const readbackPassed=result.readbackPassed && await binding.readback(this.options.workspaceRoot); + const evidence:VerificationEvidence={id:evidenceId,backendId:state.binding.backendId,workItemId:state.workItemId,attemptId:state.attemptId,planId:plan.id,planVersion:plan.version,stepId,specDigest:binding.specDigest,environment:binding.environment,...(result.environmentSnapshot ? {environmentSnapshot:result.environmentSnapshot}:{}),before,after,startedAt,finishedAt:this.now(),exitCode:result.exitCode,outcome:before.digest !== after.digest || before.mutationEpoch !== after.mutationEpoch || result.exitCode === null ? "inconclusive" : result.exitCode === 0 && readbackPassed ? "passed" : "failed",readbackPassed,acceptanceMethods:result.acceptanceMethods,artifact:this.artifacts.put(result.output,{mediaType:"text/plain"})}; + return {success:true,data:evidence}; + }}; + privateRegistry.register(tool); + const call=privateRegistry.executeDetailed(tool.name,{}, {...context,expectedCapabilityDigest:capabilityDigest(tool),deadline:verificationDeadline,callId:runId,runId:state.attemptId,signal:context.signal ? AbortSignal.any([context.signal,this.lifetime.signal]) : this.lifetime.signal},this.executions);this.inflight.add(call);let execution:ToolExecution; + try{execution=await call;}finally{this.inflight.delete(call);} + if(execution.outcome === "unknown"){ + await this.admissions.run(async()=>{state.state="reconciliation_required";state.unknownExecutions.push(execution.receipt.executionId);this.persist("verification",{verificationRunId:runId,stepId,outcome:"unknown"},{executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId});}); + throw new JournalInvariantError("Verification execution is uncertain; reconcile before another run"); + } + if(!execution.result.success){delete state.verificationRuns[runId];this.persist("verification",{verificationRunId:runId,stepId,outcome:"not_started"},{executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId});throw new JournalInvariantError(execution.result.error ?? "Verification was not admitted");} + const evidence=execution.result.data as VerificationEvidence; + await this.admissions.run(async()=>{ + state.evidence[stepId]=evidence;delete state.verificationRuns[runId];this.accountOutput(execution.receipt.executionId,evidence.artifact.sizeBytes); + this.persist("verification",{verificationRunId:runId,evidenceId:evidence.id,stepId,artifactRef:evidence.artifact.id},{executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId}); + try{await this.recordOutput(execution.receipt.executionId);}catch(error){state.state="reconciliation_required";this.persist("reconciled",{disposition:"verification_output_limit",executionRef:execution.receipt.executionId});throw error;} + }); + await this.submitEvidence(evidence,context);return snapshotJson(evidence) as VerificationEvidence; + } + private async submitEvidence(evidence:VerificationEvidence,context:ToolExecutionContext={}):Promise { + await this.lifecycle.run(async()=>{const state=this.requireState();await this.grant();await this.fresh();await this.backendOperation("evidence",evidence as unknown as Record,ref=>this.options.backend.submitEvidence(state.lease!,evidence,ref,this.requestSignal(context)),evidence.id,undefined,context);this.persist("evidence_submitted",{evidenceId:evidence.id,stepId:evidence.stepId});}); + } + private scheduleRenewal():void { + this.stopTimer();const state=this.state;if(this.stopping || !state?.lease || !["running","verifying"].includes(state.state))return; + const remaining=state.lease.expiresAt-this.now();const delay=Math.max(1,Math.floor(remaining/3)); + this.timer=setTimeout(()=>{void this.renew().catch(()=>{});},delay);this.timer.unref?.(); + } + private stopTimer():void {if(this.timer)clearTimeout(this.timer);this.timer=undefined;} + /** Trusted application/test seam; absent from model-callable tools. */ + async renew():Promise { + await this.lifecycle.run(async()=>{ + const state=this.requireState();if(this.stopping || !["running","verifying"].includes(state.state))return; + try{ + const grant=await this.grant();await this.fresh(); + const payload={leaseId:state.lease!.id,action:"heartbeat",payload:{leaseSeconds:this.options.leaseSeconds ?? 120}}; + const operation=this.operation("heartbeat",payload);state.operations[operation.id]={ref:operation,payload,status:"started"};this.persist("operation_started",{operationId:operation.id,kind:"heartbeat"}); + const tool={name:"_of_attempt_renew",description:"Private adapter-bound lease renewal",inputSchema:{type:"object" as const,properties:{},additionalProperties:false},contract:{version:"1",useWhen:[],doNotUseWhen:[],sideEffects:["Renew selected work lease"],commitmentClass:"C1" as const,idempotency:"idempotent_with_key" as const,requiresAuthority:true,mutation:true},handler:async(_params:Record,boundContext?:ToolExecutionContext)=>({success:true,data:await this.options.backend.transition(state.lease!,"heartbeat",payload.payload,operation.backendKey ?? operation.id,boundContext?.signal ?? this.lifetime.signal)})}; + const privateRegistry=this.options.registry.project({}, {authorize:async()=>{try{const current=await this.grant();return {decision:"allow",authorityGrantRefs:[current.id],expiresAt:current.expiresAt};}catch{return {decision:"deny",reasonCodes:["renewal_grant_unavailable"]};}}}); + const execution=await privateRegistry.createRenewalRunner(tool,this.executions)({}, {runId:state.attemptId,callId:operation.id,deadline:state.lease!.expiresAt,authorityGrantRefs:[grant.id]}); + if(!execution.result.success || execution.outcome !== "succeeded")throw new JournalInvariantError("Lease renewal failed or uncertain"); + const receipt=execution.result.data as BackendReceipt;this.validateBackendReceipt(receipt,operation);state.lease=receipt.lease;state.operations[operation.id]!.status="settled";state.operations[operation.id]!.backendReceipt=receipt;this.persist("operation_settled",{operationId:operation.id,backendReceiptRef:receipt.id});this.persist("renewed",{expiresAt:state.lease!.expiresAt});this.scheduleRenewal(); + }catch(error){state.renewalFailed=true;state.state="reconciliation_required";this.stopTimer();this.persist("reconciled",{disposition:"renewal_failed"});throw error;} + }); + } + private safeStatus():Record { + const state=this.requireState(); + const stepNames=new Set(Object.values(this.options.steps).map(step=>step.toolName)); + const last=this.executions?.recover().filter(execution=>execution.settled && stepNames.has((execution.settled.receipt as {toolName:string}).toolName)).at(-1); + const lastStepExecution=last ? Buffer.byteLength(JSON.stringify(last.settled)) <= 16*1024 ? {executionId:last.executionId,...last.settled} : {executionId:last.executionId,receipt:last.settled!.receipt,artifactRef:state.outputArtifacts[last.executionId],resultAvailable:true} : undefined; + return {lastStepExecution,effectResolutions:state.effectResolutions,outputArtifactRefs:Object.values(state.outputArtifacts),attemptId:state.attemptId,workItemId:state.workItemId,state:state.state,mutationEpoch:state.mutationEpoch,actions:state.actions,outputBytes:state.outputBytes,evidence:Object.values(state.evidence),unresolvedOperations:Object.values(state.operations).filter(op=>op.status === "started").map(op=>op.ref),unknownExecutions:[...state.unknownExecutions],inflightStepIds:Object.keys(state.inflightSteps),lease:state.lease ? {id:state.lease.id,expiresAt:state.lease.expiresAt}:undefined}; + } + async status(context:ToolExecutionContext={}):Promise> {if(!this.state)return {state:"prepared",active:false};const snapshot=await this.options.backend.inspect(this.state.workItemId,context.signal);return snapshotJson({...this.safeStatus(),backendState:snapshot.state}) as Record;} + async reconcile(context:ToolExecutionContext={}):Promise> { + return this.admissions.run(async()=>this.lifecycle.run(async()=>{ + const state=this.requireState();this.assertAdmissionContext(context);this.ensureOwner();if(this.inflight.size)throw new JournalInvariantError("Started calls still draining");await this.grant(); + const saved=this.executions!.recover();const recoveredOutputRefs:string[]=[]; + for(const execution of saved){ + if(!execution.settled || state.accountedOutputs[execution.executionId])continue; + const receipt=execution.settled.receipt as {toolName:string};if(receipt.toolName === "_of_attempt_renew")continue; + const evidence=(execution.settled.result as {data?:Partial}).data; + const bytes=receipt.toolName.startsWith("_of_verify_") && evidence?.artifact ? evidence.artifact.sizeBytes:Buffer.byteLength(execution.settled.modelContent as string); + this.accountOutput(execution.executionId,bytes);recoveredOutputRefs.push(execution.executionId); + } + if(recoveredOutputRefs.length)this.persist("reconciled",{recoveredOutputRefs}); + for(const [id,run] of Object.entries(state.oracleRuns)) { + const known=saved.find(execution=>execution.events.some(event=>event.data.callId === id || (event.data.receipt as {callId?:string})?.callId === id)); + if(!known && !state.executionFileIdentity)continue; + delete state.oracleRuns[id];this.persist("reconciled",{oracleRunId:id,originalExecutionId:run.originalExecutionId,readbackExecutionId:known?.executionId,disposition:known?.settled ? "readback_result_recovered" : known?.state === "unknown" ? "readback_result_unknown":"readback_not_started"}); + } + for(const [id,step] of Object.entries(state.inflightSteps)) { + const known=this.executions!.recover().find(execution=>execution.events.some(event=>event.data.callId === id || (event.data.receipt as {callId?:string})?.callId === id)); + if(!known && state.executionFileIdentity || known?.state === "not_started"){delete state.inflightSteps[id];this.persist("reconciled",{stepIntentId:id,executionRef:known?.executionId,disposition:"not_started"});continue;} + if(known?.settled){delete state.inflightSteps[id];const receipt=known.settled!.receipt as {executionOutcome:string;executionId:string};this.accountOutput(receipt.executionId,Buffer.byteLength(known.settled!.modelContent as string));if(receipt.executionOutcome === "unknown"){if(!state.unknownExecutions.includes(receipt.executionId))state.unknownExecutions.push(receipt.executionId);state.unknownSteps[receipt.executionId]=step;}this.persist("step",{stepIntentId:id,stepKey:step.stepKey,recovered:true},{executionId:receipt.executionId,receiptRef:receipt.executionId});} + else if(known?.state === "unknown"){if(!state.unknownExecutions.includes(known.executionId))state.unknownExecutions.push(known.executionId);state.unknownSteps[known.executionId]=step;} + } + for(const [id,run] of Object.entries(state.verificationRuns)) { + const known=this.executions!.recover().find(execution=>execution.events.some(event=>event.data.callId === id || (event.data.receipt as {callId?:string})?.callId === id)); + if(!known && state.executionFileIdentity || known?.state === "not_started"){delete state.verificationRuns[id];this.persist("reconciled",{verificationRunId:id,executionRef:known?.executionId,disposition:"not_started"});continue;} + if(!known?.settled)continue; + const receipt=known.settled!.receipt as {executionOutcome:string;executionId:string;phase:string}; + if(receipt.executionOutcome === "unknown"){if(!state.unknownExecutions.includes(receipt.executionId))state.unknownExecutions.push(receipt.executionId);continue;} + const result=known.settled!.result as {success:boolean;data?:VerificationEvidence}; + if(result.success && result.data?.id === run.evidenceId){this.accountOutput(receipt.executionId,result.data.artifact.sizeBytes);state.evidence[run.stepId]=result.data;delete state.verificationRuns[id];this.persist("verification",{verificationRunId:id,evidenceId:result.data.id,stepId:run.stepId,recovered:true},{executionId:receipt.executionId,receiptRef:receipt.executionId});} + else if(receipt.phase === "not_started"){delete state.verificationRuns[id];this.persist("verification",{verificationRunId:id,stepId:run.stepId,disposition:"not_started"},{executionId:receipt.executionId,receiptRef:receipt.executionId});} + } + for(const id of Object.keys(state.accountedOutputs))await this.recordOutput(id); + await this.grant(); + for(const operation of Object.values(state.operations).filter(op=>op.status === "started")) { + const result=await this.options.backend.reconcileOperation(operation.ref,this.requestSignal(context));this.assertAdmissionContext(context); + if(result.state === "settled"){this.validateBackendReceipt(result.receipt,operation.ref);operation.status="settled";operation.backendReceipt=result.receipt;if(result.receipt.lease)state.lease=result.receipt.lease;this.persist("operation_settled",{operationId:operation.ref.id,backendReceiptRef:result.receipt.id,reconciled:true});} + else if(result.state === "authoritatively_not_started"){ + // Reconciliation establishes non-execution but does not implicitly retry effects. + operation.status="settled";this.persist("operation_settled",{operationId:operation.ref.id,disposition:"authoritatively_not_started"}); + } + } + if(!Object.values(state.operations).some(operation=>operation.status === "started")) { + for(const [executionId,intent] of Object.entries(state.unknownSteps))await this.reconcileEffect(executionId,intent,context); + } + let snapshot=await this.options.backend.inspect(state.workItemId); + const unresolved=Object.keys(state.inflightSteps).length || Object.keys(state.verificationRuns).length || state.unknownExecutions.length || Object.values(state.operations).some(operation=>operation.status === "started"); + if(!unresolved && ["ready","released"].includes(snapshot.state) && !state.lease && !Object.values(state.operations).some(operation=>operation.ref.kind === "open" && operation.backendReceipt)) { + const preview=await this.prepare(state.workItemId,context);await this.grant(); + if(digestJson(preview.binding) !== digestJson(state.binding) || !preview.snapshot.claimable || !preview.snapshot.dependenciesSatisfied)throw new JournalInvariantError("Prepared attempt scope changed or work is no longer claimable"); + await this.options.grantResolver.reserveAttempt?.(state.binding,state.attemptId); + const id=randomUUID();const payload={workItemId:state.workItemId,claimOwner:this.options.claimOwner,leaseSeconds:this.options.leaseSeconds ?? 120,idempotencyKey:id}; + const receipt=await this.backendOperation("open",payload,async()=>{const lease=await this.options.backend.open(payload,this.requestSignal(context));this.validateLease(lease);return {id:lease.id,operationId:id,kind:"open",payloadDigest:digestJson(payload),workItemId:state.workItemId,state:"claimed",lease};},id,undefined,context); + state.lease=receipt.lease;snapshot=await this.options.backend.inspect(state.workItemId,this.requestSignal(context)); + } + if(!unresolved && snapshot.state === "claimed") { + await this.fresh(["claimed"]);await this.grant(); + await this.backendOperation("start",{leaseId:state.lease!.id,action:"start",payload:{}},ref=>this.options.backend.transition(state.lease!,"start",{},ref.backendKey ?? ref.id,this.requestSignal(context)),undefined,undefined,context); + } + if(!unresolved && ["released","needs_review","blocked","failed"].includes(snapshot.state)) { + const terminal=Object.values(state.operations).find(operation=>operation.backendReceipt?.state === snapshot.state && ["release","review","block","fail"].includes(operation.ref.kind)); + if(terminal){state.state=snapshot.state as WorkState;this.persist(snapshot.state === "failed" ? "failed" : snapshot.state === "needs_review" ? "review" : "blocked",{disposition:snapshot.state,backendReceiptRef:terminal.backendReceipt!.id,reconciled:true});this.releaseOwnership();return this.safeStatus();} + } + if(snapshot.state === "completed") { + const completion=Object.values(state.operations).find(op=>op.ref.kind === "complete" && op.backendReceipt?.state === "completed"); + if(completion && !Object.keys(state.inflightSteps).length && !Object.keys(state.verificationRuns).length && !state.unknownExecutions.length){await this.checkProof();state.state="completed";this.persist("completed",{backendReceiptRef:completion.backendReceipt!.id,reconciled:true});} + } else if(!Object.keys(state.inflightSteps).length && !Object.keys(state.verificationRuns).length && !state.unknownExecutions.length && !Object.values(state.operations).some(op=>op.status === "started")) { + const {plan}=await this.fresh(); + for(const evidence of Object.values(state.evidence)) { + if(plan.coverage[evidence.stepId]?.id === evidence.id)continue; + const existing=state.operations[evidence.id]; + if(existing?.status === "started")continue; + if(existing?.backendReceipt){state.state="blocked";this.persist("blocked",{disposition:"authoritative_evidence_coverage_changed",evidenceRef:evidence.id});return this.safeStatus();} + await this.backendOperation("evidence",evidence as unknown as Record,ref=>this.options.backend.submitEvidence(state.lease!,evidence,ref,this.requestSignal(context)),existing ? randomUUID():evidence.id,existing ? evidence.id : undefined,context); + } + state.renewalFailed=false;state.state=Object.keys(state.evidence).length ? "verifying" : "running";this.persist("reconciled",{disposition:"ownership_confirmed"});this.scheduleRenewal(); + } + return this.safeStatus(); + })); + } + private async reconcileEffect(originalExecutionId:string,intent:StepIntent,context:ToolExecutionContext):Promise { + const state=this.requireState();const sourceStep=this.options.steps[intent.stepKey];const step=sourceStep ? Object.freeze({...sourceStep}):undefined; + if(!step?.reconcileEffect || !step.prepareReconciliation || !step.reconciliationSpecDigest || !intent.reconciliationMetadata || !intent.oracleDigest)return; + const metadata=snapshotJson(intent.reconciliationMetadata,"captured readback metadata"); + const readback=step.reconcileEffect; + const validateCatalog=(grant:ResolvedWorkGrant):void=>{ + const current=this.options.steps[intent.stepKey];const tool=current ? this.options.registry.get(current.toolName):undefined; + const digest=current?.prepareReconciliation && current.reconcileEffect && current.reconciliationSpecDigest ? digestJson({specDigest:current.reconciliationSpecDigest,prepare:Function.prototype.toString.call(current.prepareReconciliation),readback:Function.prototype.toString.call(current.reconcileEffect),capabilityDigest:current.capabilityDigest}):undefined; + if(!current || current.reconcileEffect !== readback || digest !== intent.oracleDigest || current.capabilityDigest !== intent.capabilityDigest || current.toolName !== step.toolName || !tool || capabilityDigest(tool) !== intent.capabilityDigest || grant.stepBindings[intent.stepKey]?.toolName !== current.toolName || grant.stepBindings[intent.stepKey]?.capabilityDigest !== intent.capabilityDigest || !grant.actions.includes("read") || !state.preview.snapshot.contract.actions.includes("read"))throw new JournalInvariantError("Effect readback catalog or authority changed"); + this.validatePaths(intent.admittedPaths,grant); + }; + const grant=await this.grant();await this.fresh(["running"]);validateCatalog(grant); + if(state.actions >= grant.limits.maxActions)throw new JournalInvariantError("Effect readback action budget exhausted"); + await this.options.grantResolver.reserveAttempt?.(state.binding,state.attemptId);await this.options.grantResolver.reserveAction?.(state.binding,state.attemptId);this.assertAdmissionContext(context);state.actions++;const oracleRunId=randomUUID();state.oracleRuns[oracleRunId]={originalExecutionId,oracleDigest:intent.oracleDigest,inputDigest:intent.inputDigest}; + this.persist("reconciled",{originalExecutionId,oracleRunId,oracleIntent:true,oracleDigest:intent.oracleDigest,inputDigest:intent.inputDigest}); + const privateRegistry=this.options.registry.project({names:[]},{authorize:async()=>{try{const current=await this.grant();await this.fresh(["running"]);validateCatalog(current);return current.actions.includes("read") ? {decision:"allow",authorityGrantRefs:[current.id],expiresAt:current.expiresAt}:{decision:"deny",reasonCodes:["readback_read_permission_missing"]};}catch{return {decision:"deny",reasonCodes:["readback_authority_unavailable"]};}}}); + const definition={name:`_of_readback_${originalExecutionId.replaceAll("-","_")}`,description:"Read the independent trusted effect oracle; never dispatch the original effect",inputSchema:{type:"object" as const,properties:{},additionalProperties:false},replayPolicy:"read_only" as const,executionMode:"parallel-safe" as const,contract:{version:"1",useWhen:[],doNotUseWhen:[],sideEffects:[],mutation:false,commitmentClass:"C0" as const,idempotency:"idempotent" as const,requiresAuthority:true,bounds:{originalExecutionId,oracleDigest:intent.oracleDigest,inputDigest:intent.inputDigest,capabilityDigest:intent.capabilityDigest}},handler:async(_params:Record,boundContext?:ToolExecutionContext)=>{const current=await this.grant();await this.fresh(["running"]);validateCatalog(current);return {success:true,data:await readback({workspaceRoot:this.options.workspaceRoot,metadata,inputDigest:intent.inputDigest,capabilityDigest:intent.capabilityDigest,signal:boundContext?.signal ?? this.requestSignal(context)})};}}; + privateRegistry.register(definition); + const execution=await privateRegistry.executeDetailed(definition.name,{}, {...context,expectedCapabilityDigest:capabilityDigest(definition),callId:oracleRunId,runId:state.attemptId,signal:this.requestSignal(context),deadline:Math.min(context.deadline ?? Infinity,grant.expiresAt,state.startedAt+grant.limits.maxDurationMs)},this.executions); + this.accountOutput(execution.receipt.executionId,Buffer.byteLength(execution.modelContent));delete state.oracleRuns[oracleRunId];this.persist("reconciled",{originalExecutionId,readbackExecutionId:execution.receipt.executionId,disposition:"readback_recorded"},{executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId}); + await this.recordOutput(execution.receipt.executionId);await this.grant();await this.fresh(["running"]);validateCatalog(await this.grant()); + const proof=execution.result.data as {effect?:unknown;sourceRefs?:unknown}|undefined; + if(execution.receipt.cancellationObserved || execution.receipt.deadlineObserved || context.signal?.aborted || context.deadline !== undefined && context.deadline <= this.now() || execution.outcome !== "succeeded" || !execution.result.success || proof?.effect !== "verified" || !Array.isArray(proof.sourceRefs) || !proof.sourceRefs.length || proof.sourceRefs.some(ref=>typeof ref !== "string" || !ref))return; + state.effectResolutions[originalExecutionId]={effect:"verified",readbackExecutionId:execution.receipt.executionId,sourceRefs:proof.sourceRefs as string[]};delete state.unknownSteps[originalExecutionId];state.unknownExecutions=state.unknownExecutions.filter(id=>id !== originalExecutionId); + for(const [id,pending] of Object.entries(state.inflightSteps))if(pending === intent || pending.stepKey === intent.stepKey && pending.inputDigest === intent.inputDigest && pending.reconciliationMetadata === intent.reconciliationMetadata)delete state.inflightSteps[id]; + this.persist("reconciled",{originalExecutionId,readbackExecutionId:execution.receipt.executionId,disposition:"effect_independently_verified"},{executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId}); + } + private async checkProof():Promise { + const state=this.requireState();const grant=await this.grant("completion");const {snapshot,plan}=await this.fresh(); + if(snapshot.humanReviewRequired || !plan.steps.some(step=>step.required) || Object.keys(state.inflightSteps).length || Object.keys(state.verificationRuns).length || state.unknownExecutions.length || Object.values(state.operations).some(operation=>operation.status === "started" && operation.ref.kind !== "complete"))throw new JournalInvariantError("Review, missing proof or uncertain operations prevent completion"); + const covered=new Set(); + const criterionMethods=new Set(snapshot.contract.acceptanceCriteria.map(criterion=>criterion.method)); + for(const step of plan.steps.filter(step=>step.required || state.evidence[step.id]?.acceptanceMethods.some(method=>criterionMethods.has(method)))) { + const binding=this.options.verificationBindings.find(binding=>binding.stepId === step.id && binding.specDigest === step.specDigest && binding.environment === step.environment); + const evidence=state.evidence[step.id];const authoritative=plan.coverage[step.id]; + if(!binding || !evidence || authoritative?.id !== evidence.id || authoritative.outcome !== "passed" || digestJson(authoritative) !== digestJson(evidence) || evidence.outcome !== "passed" || evidence.before.mutationEpoch !== state.mutationEpoch || evidence.after.mutationEpoch !== state.mutationEpoch || evidence.before.digest !== evidence.after.digest || evidence.specDigest !== step.specDigest || evidence.environment !== step.environment || evidence.planId !== plan.id || evidence.planVersion !== plan.version || evidence.attemptId !== state.attemptId)throw new JournalInvariantError("Current active proof missing"); + const current=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch); + const artifact=this.artifacts.metadata(evidence.artifact.id); + if(digestJson(artifact) !== digestJson(evidence.artifact))throw new JournalInvariantError("Evidence artifact metadata changed"); + if(current.digest !== evidence.after.digest || !await binding.readback(this.options.workspaceRoot))throw new JournalInvariantError("Verification subject changed or independent readback failed"); + for(const method of evidence.acceptanceMethods)covered.add(method); + } + if(!snapshot.contract.acceptanceCriteria.length || !snapshot.contract.acceptanceCriteria.every(criterion=>grant.acceptanceMethods.includes(criterion.method) && covered.has(criterion.method)))throw new JournalInvariantError("Acceptance method coverage missing"); + } + async finish(disposition:"complete"|"review"|"block"|"release"="complete",context:ToolExecutionContext={}):Promise> { + return this.admissions.run(async()=>this.lifecycle.run(async()=>{ + const state=this.requireState();this.assertAdmissionContext(context);this.ensureOwner();if(this.stopping || !(disposition === "release" ? ["running","verifying","blocked","reconciliation_required"] : ["running","verifying"]).includes(state.state) || this.inflight.size || Object.keys(state.inflightSteps).length || Object.keys(state.verificationRuns).length || state.unknownExecutions.length || Object.values(state.operations).some(operation=>operation.status === "started"))throw new JournalInvariantError("Attempt cannot finalize while draining or uncertain"); + await this.grant("execution",disposition === "release"); + if(disposition === "release" && this.options.backend.capabilities.expiredLeaseRelease) { + const preview=await this.prepare(state.workItemId);const lease=preview.snapshot.lease; + if(digestJson(preview.binding) !== digestJson(state.binding) || !state.lease || !lease || lease.id !== state.lease.id || lease.owner !== state.lease.owner)throw new JournalInvariantError("Explicit release cannot adopt successor lease or changed scope"); + this.validateLease(lease,false);state.lease=lease; + }else await this.fresh(); + this.stopTimer();state.state="finalizing";this.persist("reconciled",{finalizationIntent:true,disposition}); + if(disposition === "release") { + const receipt=await this.backendOperation("release",{leaseId:state.lease!.id},ref=>this.options.backend.release(state.lease!,ref.backendKey ?? ref.id,this.requestSignal(context)),undefined,undefined,context); + const snapshot=await this.options.backend.inspect(state.workItemId);if(snapshot.state !== "released" || receipt.state !== "released"){state.state="reconciliation_required";this.persist("reconciled",{disposition:"release_readback_mismatch"});return this.safeStatus();} + state.state="released";this.persist("blocked",{disposition:"released",backendReceiptRef:receipt.id});this.releaseOwnership();return this.safeStatus(); + } + let reason="Explicit terminal disposition"; + if(disposition === "complete")try{await this.checkProof();}catch(error){disposition="review";reason=error instanceof Error ? error.message : "Completion proof unavailable";} + const action=disposition === "complete" ? "complete" : disposition === "review" ? "review" : "block"; + const terminalPayload=action === "complete" ? {evidenceIds:Object.values(state.evidence).map(evidence=>evidence.id),mutationEpoch:state.mutationEpoch} : {reason}; + const payload={leaseId:state.lease!.id,action,payload:terminalPayload}; + const receipt=await this.backendOperation(action,payload,async ref=>{ + // Proof collection contains awaited readbacks. Authority can change while + // they run, so fence the actual terminal dispatch with current grants. + await this.grant("execution"); + if(action === "complete")await this.grant("completion"); + this.assertAdmissionContext(context); + return this.options.backend.transition(state.lease!,action,payload.payload,ref.backendKey ?? ref.id,this.requestSignal(context)); + },undefined,undefined,context); + const snapshot=await this.options.backend.inspect(state.workItemId);const expected=action === "complete" ? "completed" : action === "review" ? "needs_review" : "blocked"; + if(receipt.state !== expected || snapshot.state !== expected){state.state="reconciliation_required";this.persist("reconciled",{disposition:"terminal_readback_mismatch",backendState:snapshot.state});return this.safeStatus();} + if(action === "complete")try{await this.checkProof();}catch{state.state="reconciliation_required";this.persist("reconciled",{disposition:"completion_assurance_failed",backendState:snapshot.state});return this.safeStatus();} + state.state=expected;this.persist(action === "complete" ? "completed" : action === "review" ? "review" : "blocked",{backendReceiptRef:receipt.id,reason:action === "complete" ? undefined : reason});this.releaseOwnership();return this.safeStatus(); + })); + } + async shutdown():Promise { + this.stopping=true;this.stopTimer();this.lifetime.abort(); + await Promise.allSettled([...this.inflight]); + await this.admissions.run(async()=>{});await this.lifecycle.run(async()=>{}); + this.releaseOwnership(); + } +} diff --git a/src/framework/work/grants.ts b/src/framework/work/grants.ts new file mode 100644 index 0000000..18f6498 --- /dev/null +++ b/src/framework/work/grants.ts @@ -0,0 +1,193 @@ +/** Trusted local grant custody. Only CLI/application code may issue grants. + * Same-user processes able to write this directory can change local authority; + * stronger custody requires OS isolation and host-native authenticated issuers. + */ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { digestValue } from "../runs.js"; +import { snapshotJson } from "../session.js"; +import { acquireJournalOwnership, initializePrivateRoot, JournalInvariantError, SecureJsonlJournal, type JournalEnvelope } from "../journal-file.js"; +import type { WorkAuthorizationBinding, WorkGrantResolver, ResolvedWorkGrant } from "./types.js"; + +export type GrantPurpose = "execution" | "completion"; +export interface GrantRecord extends ResolvedWorkGrant { + schemaVersion: 1; + purpose: GrantPurpose; + decisionRef: string; + issuer: { kind: "local_user"; reference: string }; + issuedAt: number; + binding: WorkAuthorizationBinding; + bindingDigest: string; + executionGrantId?: string; + revokedAt?: number; +} +export type IssueGrantOptions = Omit & { purpose: GrantPurpose; executionGrantId?: string }; +export interface GrantSummary { + id: string; purpose: GrantPurpose; decisionRef: string; issuedAt: number; + expiresAt: number; workItemId: string; backendId: string; + contractDigest: string; planDigest: string; roots: string[]; + actions: string[]; completion: boolean; revokedAt?: number; + limits: ResolvedWorkGrant["limits"]; +} +interface GrantEvent extends JournalEnvelope { + eventId: string; timestamp: number; + type: "issued" | "revoked" | "attempt" | "action" | "output"; + grantId: string; record?: GrantRecord; attemptId?: string; bytes?: number; outputExecutionId?: string; +} +interface Usage { startedAt: number; actions: number; outputBytes: number; } + +function jsonCopy(value: T): T { return snapshotJson(JSON.parse(JSON.stringify(value)) as T); } +function bindingDigest(binding: WorkAuthorizationBinding): string { return digestValue(jsonCopy(binding)); } +function opaqueId(id: string): void { + if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)) throw new JournalInvariantError("Invalid grant reference"); +} +function assertRecord(record: GrantRecord): void { + if (!record || typeof record !== "object" || record.schemaVersion !== 1 + || !["execution", "completion"].includes(record.purpose) + || record.issuer?.kind !== "local_user" || !record.issuer.reference + || !Number.isFinite(record.issuedAt) || !Number.isFinite(record.expiresAt) + || record.expiresAt <= record.issuedAt || record.completion !== (record.purpose === "completion") + || !record.binding || bindingDigest(record.binding) !== record.bindingDigest) throw new JournalInvariantError("Invalid grant record/binding"); + opaqueId(record.id); opaqueId(record.decisionRef); + for (const key of ["backendId", "workItemId", "workspaceId", "contractDigest", "planId", "planDigest", "scopeDigest"] as const) { + if (typeof record.binding[key] !== "string" || !record.binding[key]) throw new JournalInvariantError("Invalid grant identity"); + } + if (typeof record.binding.revision !== "string" || !record.binding.revision || !Number.isSafeInteger(record.binding.planVersion) || record.binding.planVersion < 1) throw new JournalInvariantError("Invalid grant revision/plan version"); + if (record.actions.some((action) => !["read","write","test","network","vault","deploy","migrate"].includes(action))) throw new JournalInvariantError("Unsupported grant action"); + for (const list of [record.actions, record.roots, record.include, record.exclude, record.acceptanceMethods]) { + if (!Array.isArray(list) || list.some((value) => typeof value !== "string" || !value)) throw new JournalInvariantError("Invalid grant scope list"); + } + if (!record.roots.length || record.roots.some((root) => !isAbsolute(root))) throw new JournalInvariantError("Grant roots must be absolute"); + if (!record.stepBindings || typeof record.stepBindings !== "object" || Array.isArray(record.stepBindings) + || Object.values(record.stepBindings).some((step) => !step || typeof step.toolName !== "string" || !step.toolName || typeof step.capabilityDigest !== "string" || !step.capabilityDigest)) throw new JournalInvariantError("Invalid grant step bindings"); + for (const value of Object.values(record.limits ?? {})) if (!Number.isSafeInteger(value) || value <= 0) throw new JournalInvariantError("Grant limits must be positive safe integers"); + if (!record.limits || ["maxAttempts", "maxDurationMs", "maxActions", "maxOutputBytes"].some((key) => !(key in record.limits))) throw new JournalInvariantError("Grant limits missing"); + if (record.purpose === "completion") { opaqueId(record.executionGrantId ?? ""); if (!record.acceptanceMethods.length) throw new JournalInvariantError("Completion requires acceptance methods"); } +} +function validateEvent(event: Readonly, previous: readonly Readonly[]): void { + opaqueId(event.eventId); opaqueId(event.grantId); + if (!Number.isFinite(event.timestamp) || !["issued", "revoked", "attempt", "action", "output"].includes(event.type)) throw new JournalInvariantError("Invalid grant audit event"); + if (event.outputExecutionId !== undefined) { + if (event.type !== "output" || typeof event.outputExecutionId !== "string" || !event.outputExecutionId.trim()) throw new JournalInvariantError("Invalid output execution identity"); + const prior = previous.find((value) => value.type === "output" && value.grantId === event.grantId && value.attemptId === event.attemptId && value.outputExecutionId === event.outputExecutionId); + if (prior) throw new JournalInvariantError(prior.bytes === event.bytes ? "Duplicate output execution identity" : "Conflicting output execution accounting"); + } + if (event.type === "issued") { + if (!event.record || event.record.id !== event.grantId || previous.some((value) => value.grantId === event.grantId)) throw new JournalInvariantError("Grant issuance identity conflict"); + assertRecord(event.record); + } else { + if (!previous.some((value) => value.grantId === event.grantId && value.type === "issued")) throw new JournalInvariantError("Grant audit references missing issuance"); + if (["attempt", "action", "output"].includes(event.type) && (typeof event.attemptId !== "string" || !event.attemptId)) throw new JournalInvariantError("Grant usage requires attempt identity"); + if (event.type === "attempt" && previous.some((value) => value.grantId === event.grantId && value.type === "attempt" && value.attemptId === event.attemptId)) throw new JournalInvariantError("Duplicate grant attempt reservation"); + if (["action", "output"].includes(event.type) && !previous.some((value) => value.grantId === event.grantId && value.type === "attempt" && value.attemptId === event.attemptId)) throw new JournalInvariantError("Usage lacks attempt reservation"); + if (event.type === "output" && (!Number.isSafeInteger(event.bytes) || event.bytes! < 0)) throw new JournalInvariantError("Invalid grant output usage"); + } +} + +/** Persisted resolver binds current work state; model-provided grant IDs are ignored. */ +export class LocalGrantStore implements WorkGrantResolver { + readonly rootDir: string; + private readonly journal: SecureJsonlJournal; + constructor(options: { rootDir: string; clock?: () => number }) { + this.rootDir = initializePrivateRoot(options.rootDir); + this.clock = options.clock ?? Date.now; + this.journal = new SecureJsonlJournal({ rootDir: this.rootDir, streamId: "local-grants-v1", scopeDigest: digestValue(this.rootDir), schemaVersion: 1, validate: validateEvent }); + this.journal.read(); + } + private readonly clock: () => number; + private records(): GrantRecord[] { + const records = new Map(); + for (const event of this.journal.read()) { + if (event.type === "issued") records.set(event.grantId, jsonCopy(event.record!)); + if (event.type === "revoked") { const record = records.get(event.grantId)!; records.set(event.grantId, { ...record, revokedAt: event.timestamp }); } + } + return [...records.values()]; + } + private append(event: Omit): void { + const prior = this.journal.read(); + this.journal.append({ ...event, schemaVersion: 1, streamId: "local-grants-v1", scopeDigest: digestValue(this.rootDir), seq: prior.length + 1, eventId: randomUUID(), timestamp: this.clock() }, prior.length); + } + /** Trusted application operation; never expose this as a model-callable tool. */ + issue(binding: WorkAuthorizationBinding, options: IssueGrantOptions): Readonly { + const lock = acquireJournalOwnership(this.rootDir, "grant-state"); + try { + const record = jsonCopy({ ...options, schemaVersion: 1, id: randomUUID(), decisionRef: randomUUID(), issuer: { kind: "local_user", reference: typeof process.getuid === "function" ? `uid:${process.getuid()}` : "local-user" }, issuedAt: this.clock(), binding, bindingDigest: bindingDigest(binding), completion: options.purpose === "completion" }); + assertRecord(record); + if (record.expiresAt <= this.clock()) throw new JournalInvariantError("Grant expiry must be in the future"); + if (record.purpose === "completion") { + const execution = this.records().find((value) => value.id === record.executionGrantId); + if (!execution || execution.purpose !== "execution" || execution.revokedAt !== undefined || execution.expiresAt < record.expiresAt || execution.bindingDigest !== record.bindingDigest) throw new JournalInvariantError("Completion must bind an active matching execution grant"); + } + this.append({ type: "issued", grantId: record.id, record }); + return jsonCopy(record); + } finally { lock.release(); } + } + list(): readonly Readonly[] { + return this.records().map((record) => jsonCopy({ id: record.id, purpose: record.purpose, decisionRef: record.decisionRef, issuedAt: record.issuedAt, expiresAt: record.expiresAt, workItemId: record.binding.workItemId, backendId: record.binding.backendId, contractDigest: record.binding.contractDigest, planDigest: record.binding.planDigest, roots: record.roots, actions: record.actions, completion: record.completion, revokedAt: record.revokedAt, limits: record.limits })); + } + revoke(id: string): void { + opaqueId(id); + const lock = acquireJournalOwnership(this.rootDir, "grant-state"); + try { + const record = this.records().find((value) => value.id === id); + if (!record) throw new JournalInvariantError("Unknown grant"); + if (record.revokedAt === undefined) this.append({ type: "revoked", grantId: id }); + } finally { lock.release(); } + } + async resolve(binding: WorkAuthorizationBinding, purpose: GrantPurpose): Promise { + const digest = bindingDigest(binding); const now = this.clock(); const records = this.records(); + const selected = records.reverse().find((record) => { + if (record.purpose !== purpose || record.bindingDigest !== digest || record.revokedAt !== undefined || record.issuedAt > now || record.expiresAt <= now) return false; + if (purpose === "completion") return records.some((execution) => execution.id === record.executionGrantId && execution.purpose === "execution" && execution.revokedAt === undefined && execution.expiresAt > now && execution.bindingDigest === digest); + return true; + }); + return selected ? jsonCopy(selected) : undefined; + } + private usage(grantId: string): Map { + const attempts = new Map(); + for (const event of this.journal.read()) { + if (event.grantId !== grantId || !event.attemptId) continue; + if (event.type === "attempt") attempts.set(event.attemptId, { startedAt: event.timestamp, actions: 0, outputBytes: 0 }); + if (event.type === "action") { const usage = attempts.get(event.attemptId); if (!usage) throw new JournalInvariantError("Action lacks attempt reservation"); usage.actions++; } + if (event.type === "output") { const usage = attempts.get(event.attemptId); if (!usage) throw new JournalInvariantError("Output lacks attempt reservation"); usage.outputBytes += event.bytes!; } + } + return attempts; + } + async reserveAttempt(binding: WorkAuthorizationBinding, attemptId: string): Promise { + await this.updateUsage(binding, attemptId, "attempt"); + } + async reserveAction(binding: WorkAuthorizationBinding, attemptId: string): Promise { + await this.updateUsage(binding, attemptId, "action"); + } + /** A stable executor ID makes retries of acknowledged accounting effect-free. */ + async recordOutput(binding: WorkAuthorizationBinding, attemptId: string, bytes: number, executionId?: string): Promise { + await this.updateUsage(binding, attemptId, "output", bytes, executionId); + } + private async updateUsage(binding: WorkAuthorizationBinding, attemptId: string, type: "attempt" | "action" | "output", bytes?: number, executionId?: string): Promise { + if (!attemptId) throw new JournalInvariantError("Attempt identity required"); + if (executionId !== undefined && (type !== "output" || typeof executionId !== "string" || !executionId.trim())) throw new JournalInvariantError("Invalid output execution identity"); + const lock = acquireJournalOwnership(this.rootDir, "grant-state"); + try { + const grant = await this.resolve(binding, "execution"); + if (!grant) throw new JournalInvariantError("Execution grant is expired, revoked, or mismatched"); + if (type === "output" && executionId !== undefined) { + const prior = this.journal.read().find((event) => event.type === "output" && event.grantId === grant.id && event.attemptId === attemptId && event.outputExecutionId === executionId); + if (prior) { + if (prior.bytes !== bytes) throw new JournalInvariantError("Conflicting output execution accounting"); + return; + } + } + const attempts = this.usage(grant.id); const usage = attempts.get(attemptId); + if (type === "attempt") { + if (usage) return; + if (attempts.size >= grant.limits.maxAttempts) throw new JournalInvariantError("Grant attempt limit exhausted"); + } else { + if (!usage) throw new JournalInvariantError("Attempt must reserve its grant first"); + if (this.clock() - usage.startedAt >= grant.limits.maxDurationMs) throw new JournalInvariantError("Grant duration limit exhausted"); + if (type === "action" && usage.actions >= grant.limits.maxActions) throw new JournalInvariantError("Grant action limit exhausted"); + if (type === "output" && (!Number.isSafeInteger(bytes) || bytes! < 0 || usage.outputBytes + bytes! > grant.limits.maxOutputBytes)) throw new JournalInvariantError("Grant output limit exhausted"); + } + this.append({ type, grantId: grant.id, attemptId, ...(bytes === undefined ? {} : { bytes }), ...(executionId === undefined ? {} : { outputExecutionId: executionId }) }); + } finally { lock.release(); } + } +} diff --git a/src/framework/work/index.ts b/src/framework/work/index.ts new file mode 100644 index 0000000..76fa282 --- /dev/null +++ b/src/framework/work/index.ts @@ -0,0 +1,8 @@ +export type * from "./types.js"; +export * from "./controller.js"; +export * from "./journal.js"; +export * from "./local-backend.js"; +export * from "./verification.js"; +export * from "./tools.js"; +export * from "./grants.js"; +export * from "./local-fixture.js"; diff --git a/src/framework/work/journal.ts b/src/framework/work/journal.ts new file mode 100644 index 0000000..df9dd94 --- /dev/null +++ b/src/framework/work/journal.ts @@ -0,0 +1,45 @@ +/** Work lifecycle evidence references executions without copying their output. */ +import { randomUUID } from "node:crypto"; +import { assertNoAuthorityCustody } from "../execution-journal.js"; +import { JournalInvariantError, SecureJsonlJournal, type JournalEnvelope, type JournalFileLock } from "../journal-file.js"; +export type WorkJournalEventType = "prepared" | "authorized" | "claimed" | "running" | "operation_started" | "operation_settled" | "step" | "verification" | "evidence_submitted" | "renewed" | "reconciled" | "review" | "blocked" | "failed" | "completed" | "closed"; +export interface WorkJournalEvent { type: WorkJournalEventType; data: Record; executionId?: string; receiptRef?: string; } +export interface StoredWorkJournalEvent extends JournalEnvelope, WorkJournalEvent { eventId:string; timestamp:string; } +export interface WorkAttemptJournalOptions { rootDir:string; attemptId:string; scopeDigest:string; clock?:()=>Date; requireExisting?:boolean; } +export class WorkAttemptJournal { + private readonly file:SecureJsonlJournal; + constructor(private readonly options:WorkAttemptJournalOptions) { + this.file=new SecureJsonlJournal({rootDir:options.rootDir,streamId:options.attemptId,scopeDigest:options.scopeDigest,schemaVersion:1,requireExisting:options.requireExisting,validate:validateWorkEvent}); + this.file.read(); + } + acquireOwnership():JournalFileLock {return this.file.acquireOwnership();} + read():readonly Readonly[] {return this.file.read();} + record(event:WorkJournalEvent):void { + const previous=this.file.read(); + this.file.append({...event,schemaVersion:1,streamId:this.options.attemptId,scopeDigest:this.options.scopeDigest,seq:previous.length+1,eventId:randomUUID(),timestamp:(this.options.clock?.() ?? new Date()).toISOString()},previous.length); + } + /** Pure recovery projection; no backend calls, retry or effect dispatch. */ + recover(): {events:readonly Readonly[]; unresolvedOperationIds:readonly string[]; terminal:boolean} { + const events=this.read(); const unresolved=new Set(); + for(const event of events){ const id=event.data.operationId; if(typeof id !== "string")continue; if(event.type === "operation_started")unresolved.add(id); if(event.type === "operation_settled")unresolved.delete(id); } + return Object.freeze({events,unresolvedOperationIds:Object.freeze([...unresolved]),terminal:events.some(event=>["completed","failed","closed"].includes(event.type))}); + } +} +const types = new Set(["prepared","authorized","claimed","running","operation_started","operation_settled","step","verification","evidence_submitted","renewed","reconciled","review","blocked","failed","completed","closed"]); +function validateWorkEvent(event:Readonly,previous:readonly Readonly[]):void { + if(!types.has(event.type) || !event.eventId || !Number.isFinite(Date.parse(event.timestamp)) || !event.data || typeof event.data !== "object" || Array.isArray(event.data))throw new JournalInvariantError("Malformed work event"); + assertNoAuthorityCustody(event.data); + for(const key of ["result","modelContent","output","receipt"]) if(Object.hasOwn(event.data,key))throw new JournalInvariantError("Work journal must reference execution output"); + if(!previous.length && event.type !== "prepared")throw new JournalInvariantError("Work attempt must start prepared"); + if(previous.length && event.type === "prepared")throw new JournalInvariantError("Work attempt already prepared"); + if(previous.some(prior=>prior.eventId === event.eventId))throw new JournalInvariantError("Duplicate work event id"); + if(previous.some(prior=>prior.type === "closed"))throw new JournalInvariantError("Work attempt is closed"); + if(previous.some(prior=>["completed","failed"].includes(prior.type)) && event.type !== "closed")throw new JournalInvariantError("Terminal work attempt accepts only close"); + if(event.type === "step" && (!event.executionId || !event.receiptRef))throw new JournalInvariantError("Work step needs execution and receipt references"); + if(event.type === "operation_started" || event.type === "operation_settled") { + const id=event.data.operationId; if(typeof id !== "string" || !id)throw new JournalInvariantError("Operation identity required"); + const own=previous.filter(prior=>prior.data.operationId === id && (prior.type === "operation_started" || prior.type === "operation_settled")); + if(event.type === "operation_started" && own.length)throw new JournalInvariantError("Operation identity reused"); + if(event.type === "operation_settled" && own[own.length-1]?.type !== "operation_started")throw new JournalInvariantError("Settlement requires durable operation start"); + } +} diff --git a/src/framework/work/local-backend.ts b/src/framework/work/local-backend.ts new file mode 100644 index 0000000..c03c032 --- /dev/null +++ b/src/framework/work/local-backend.ts @@ -0,0 +1,99 @@ +/** Durable single-user backend. State and receipts commit together under file CAS. */ +import { randomUUID } from "node:crypto"; +import { digestJson, snapshotJson } from "../session.js"; +import { JournalInvariantError, SecureJsonlJournal, type JournalEnvelope } from "../journal-file.js"; +import type { BackendCapabilities, BackendReceipt, LeaseRef, OperationKind, OperationRef, ReconciliationResult, VerificationEvidence, VerificationPlanSnapshot, WorkBackend, WorkContract, WorkSnapshot } from "./types.js"; +export interface LocalWorkItem {workItemId:string;contract:WorkContract;plan:Omit;dependencies?:string[];humanReviewRequired?:boolean;} +export interface LocalWorkBackendOptions {rootDir:string;backendId:string;workspaceId:string;projectId?:string;workItems:readonly LocalWorkItem[];clock?:()=>number;} +interface LocalState {snapshot:WorkSnapshot;plan:VerificationPlanSnapshot;receipts:Record;openDigests:Record;} +interface LocalEvent extends JournalEnvelope {state:LocalState;} +export class LocalWorkBackend implements WorkBackend { + readonly capabilities:BackendCapabilities={version:"local-file-v1",keyedOperations:["open","start","heartbeat","evidence","complete","review","block","fail","release"],receiptLookup:["open","start","heartbeat","evidence","complete","review","block","fail","release"],missingReceiptProvesNotStarted:true,expiredLeaseRelease:true}; + private readonly items=new Map(); + constructor(private readonly options:LocalWorkBackendOptions){ + if(!options.backendId || !options.workspaceId)throw new JournalInvariantError("Local backend identity required"); + for(const item of options.workItems){if(this.items.has(item.workItemId))throw new JournalInvariantError("Duplicate local work identity");this.items.set(item.workItemId,snapshotJson(item) as LocalWorkItem);} + } + private now():number {return this.options.clock?.() ?? Date.now();} + private file(id:string):SecureJsonlJournal{ + if(!this.items.has(id))throw new JournalInvariantError("Unknown selected work item"); + return new SecureJsonlJournal({rootDir:this.options.rootDir,streamId:`local-work:${id}`,scopeDigest:digestJson({backendId:this.options.backendId,workspaceId:this.options.workspaceId,projectId:this.options.projectId}),schemaVersion:1,validate:event=>{ + if(!event.state || event.state.snapshot.backendId !== this.options.backendId || event.state.snapshot.workItemId !== id || event.state.snapshot.workspaceId !== this.options.workspaceId || event.state.snapshot.contractDigest !== digestJson(event.state.snapshot.contract))throw new JournalInvariantError("Invalid local backend state"); + }}); + } + private initial(id:string):LocalState { + const item=this.items.get(id)!;const plan={...item.plan,digest:digestJson(item.plan),coverage:{}}; + return {snapshot:{backendId:this.options.backendId,workItemId:id,workspaceId:this.options.workspaceId,...(this.options.projectId ? {projectId:this.options.projectId}:{}),revision:"1",contract:item.contract,contractDigest:digestJson(item.contract),state:"ready",claimable:true,dependenciesSatisfied:true,humanReviewRequired:item.humanReviewRequired ?? false,sourceRefs:[`local:${this.options.backendId}:${id}`]},plan,receipts:{},openDigests:{}}; + } + private load(id:string):LocalState {const events=this.file(id).read();return structuredClone(events[events.length-1]?.state ?? this.initial(id));} + private mutate(id:string,fn:(state:LocalState)=>T):T { + const file=this.file(id);const owner=file.acquireOwnership(); + try{const events=file.read();const state=structuredClone(events[events.length-1]?.state ?? this.initial(id));const result=fn(state);file.append({schemaVersion:1,streamId:`local-work:${id}`,scopeDigest:digestJson({backendId:this.options.backendId,workspaceId:this.options.workspaceId,projectId:this.options.projectId}),seq:events.length+1,state},events.length);return snapshotJson(result) as T;}finally{owner.release();} + } + async inspect(id:string):Promise { + const state=this.load(id);const item=this.items.get(id)!; + const dependenciesSatisfied=(item.dependencies ?? []).every(dep=>this.load(dep).snapshot.state === "completed"); + return snapshotJson({...state.snapshot,dependenciesSatisfied,claimable:dependenciesSatisfied && ["ready","released"].includes(state.snapshot.state) && (!state.snapshot.lease || state.snapshot.lease.expiresAt <= this.now())}) as WorkSnapshot; + } + async getVerificationPlan(id:string):Promise{return snapshotJson(this.load(id).plan) as VerificationPlanSnapshot;} + async open(input:{workItemId:string;claimOwner:string;leaseSeconds:number;idempotencyKey:string}):Promise { + if(!input.claimOwner || !Number.isFinite(input.leaseSeconds) || input.leaseSeconds <= 0 || !input.idempotencyKey)throw new JournalInvariantError("Invalid local claim"); + const dependenciesSatisfied=(this.items.get(input.workItemId)?.dependencies ?? []).every(dep=>this.load(dep).snapshot.state === "completed"); + return this.mutate(input.workItemId,state=>{ + const digest=digestJson(input);const prior=state.receipts[input.idempotencyKey]; + if(prior){if(state.openDigests[input.idempotencyKey] !== digest)throw new JournalInvariantError("Conflicting idempotency payload");return prior.lease!;} + if(!dependenciesSatisfied || !["ready","released"].includes(state.snapshot.state) || state.snapshot.lease && state.snapshot.lease.expiresAt > this.now())throw new JournalInvariantError("Work is not claimable"); + const lease:LeaseRef={id:randomUUID(),backendId:this.options.backendId,workItemId:input.workItemId,owner:input.claimOwner,expiresAt:this.now()+input.leaseSeconds*1000}; + state.snapshot.lease=lease;state.snapshot.state="claimed";state.snapshot.claimable=false; + state.openDigests[input.idempotencyKey]=digest; + state.receipts[input.idempotencyKey]={id:randomUUID(),operationId:input.idempotencyKey,kind:"open",payloadDigest:digest,workItemId:input.workItemId,state:"claimed",lease};return lease; + }); + } + private assertLease(state:LocalState,lease:LeaseRef):LeaseRef { + const live=state.snapshot.lease; + if(!live || lease.backendId !== this.options.backendId || live.id !== lease.id || live.owner !== lease.owner || live.expiresAt <= this.now())throw new JournalInvariantError("Expired or mismatched local lease");return live; + } + private keyed(state:LocalState,key:string,kind:OperationKind,payload:unknown,fn:()=>Partial):BackendReceipt { + const digest=digestJson(payload);const old=state.receipts[key]; + if(old){if(old.kind !== kind || old.payloadDigest !== digest)throw new JournalInvariantError("Conflicting idempotency payload");return old;} + if(!key)throw new JournalInvariantError("Operation key required"); + const receipt:BackendReceipt={id:randomUUID(),operationId:key,kind,payloadDigest:digest,workItemId:state.snapshot.workItemId,state:state.snapshot.state,...fn()}; + receipt.state=state.snapshot.state;state.receipts[key]=receipt;return receipt; + } + async transition(lease:LeaseRef,action:Exclude,payload:Record,key:string):Promise { + return this.mutate(lease.workItemId,state=>this.keyed(state,key,action,{leaseId:lease.id,action,payload},()=>{ + const live=this.assertLease(state,lease); + if(action === "start"){if(state.snapshot.state !== "claimed")throw new JournalInvariantError("Start requires claim");state.snapshot.state="running";} + else if(action === "heartbeat") {if(!["claimed","running"].includes(state.snapshot.state))throw new JournalInvariantError("Terminal lease cannot renew");const seconds=payload.leaseSeconds;if(typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)throw new JournalInvariantError("Lease duration required");live.expiresAt=this.now()+seconds*1000;} + else { + if(!["claimed","running"].includes(state.snapshot.state))throw new JournalInvariantError("Transition requires active work"); + if(action === "complete" && (state.snapshot.humanReviewRequired || !state.plan.steps.some(step=>step.required) || !state.plan.steps.filter(step=>step.required).every(step=>state.plan.coverage[step.id]?.outcome === "passed")))throw new JournalInvariantError("Completion proof missing or review required"); + state.snapshot.state=action === "complete" ? "completed" : action === "review" ? "needs_review" : action === "block" ? "blocked" : "failed"; + } + return {lease:{...live}}; + })); + } + async submitEvidence(lease:LeaseRef,evidence:VerificationEvidence,operation:OperationRef):Promise { + if(operation.kind !== "evidence" || operation.payloadDigest !== digestJson(evidence) || operation.capabilityVersion !== this.capabilities.version)throw new JournalInvariantError("Evidence operation binding mismatch"); + return this.mutate(lease.workItemId,state=>this.keyed(state,operation.backendKey ?? operation.id,"evidence",evidence,()=>{ + this.assertLease(state,lease);const step=state.plan.steps.find(step=>step.id === evidence.stepId); + if(state.snapshot.state !== "running" || evidence.backendId !== this.options.backendId || evidence.workItemId !== lease.workItemId || evidence.planId !== state.plan.id || evidence.planVersion !== state.plan.version || !step || step.specDigest !== evidence.specDigest || step.environment !== evidence.environment)throw new JournalInvariantError("Evidence work/plan binding mismatch"); + state.plan.coverage[evidence.stepId]=evidence;return {evidenceId:evidence.id}; + })); + } + async release(lease:LeaseRef,key:string):Promise{return this.mutate(lease.workItemId,state=>this.keyed(state,key,"release",{leaseId:lease.id},()=>{ + const current=state.snapshot.lease; + // Deliberate local recovery rule: an expired owner may explicitly release + // only its unchanged lease. CAS and id/owner matching fence a successor. + // This never marks effects reconciled or proves their absence. + if(!current || current.id !== lease.id || current.owner !== lease.owner || lease.backendId !== this.options.backendId || !["claimed","running"].includes(state.snapshot.state))throw new JournalInvariantError("Release lease does not match current local custody"); + state.snapshot.state="released";state.snapshot.claimable=true;delete state.snapshot.lease;return {}; + }));} + async reconcileOperation(operation:OperationRef):Promise { + for(const id of this.items.keys()) { + const state=this.load(id);const receipt=state.receipts[operation.backendKey ?? operation.id]; + if(receipt){if(receipt.kind !== operation.kind || receipt.payloadDigest !== operation.payloadDigest)throw new JournalInvariantError("Operation reconciliation payload mismatch");return {state:"settled",receipt,sourceRefs:state.snapshot.sourceRefs};} + } + return {state:"authoritatively_not_started",sourceRefs:[`local:${this.options.backendId}:committed-receipts`]}; + } +} diff --git a/src/framework/work/local-fixture.ts b/src/framework/work/local-fixture.ts new file mode 100644 index 0000000..0ad3198 --- /dev/null +++ b/src/framework/work/local-fixture.ts @@ -0,0 +1,42 @@ +/** Bounded maintenance sample: one JSON correction and independent Node verification. */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { capabilityDigest } from "../executor.js"; +import { digestJson } from "../session.js"; +import { JournalInvariantError } from "../journal-file.js"; +import type { ToolDefinition } from "../types.js"; +import { createCommandVerificationBinding, scopedPath } from "./verification.js"; +import type { VerificationPlanStep, WorkContract, WorkStepBinding } from "./types.js"; +export interface LocalFixtureBindingOptions {workspaceRoot:string;fixturePath:string;verifierPath:string;afterEffect?:()=>Promise;} +export function createLocalFixtureBindings(originalOptions:LocalFixtureBindingOptions) { + const options=Object.freeze({...originalOptions}); + const path=()=>scopedPath(options.workspaceRoot,options.fixturePath); + const inspect:ToolDefinition={name:"fixture_inspect",description:"Read the configured fixture and its revision digest",inputSchema:{type:"object",properties:{},additionalProperties:false},executionMode:"parallel-safe",replayPolicy:"read_only",annotations:{readOnlyHint:true},contract:{version:"1",useWhen:["Inspect authorized fixture"],doNotUseWhen:[],sideEffects:[],mutation:false,commitmentClass:"C0",idempotency:"idempotent",requiresAuthority:true},handler:async()=>{const bytes=readFileSync(path());return {success:true,data:{expectedDigest:digestJson(bytes.toString("utf8")),fixture:JSON.parse(bytes.toString("utf8"))}};}}; + const correct:ToolDefinition={name:"fixture_correct",description:"Correct only the configured JSON fixture, requiring the inspected revision",inputSchema:{type:"object",properties:{expectedDigest:{type:"string",minLength:1}},required:["expectedDigest"],additionalProperties:false},replayPolicy:"never",annotations:{readOnlyHint:false,destructiveHint:false},contract:{version:"1",useWhen:["Apply granted deterministic correction"],doNotUseWhen:["Revision changed"],sideEffects:["Replace configured fixture contents"],mutation:true,commitmentClass:"C1",idempotency:"non_idempotent",requiresAuthority:true,requiredVerification:["fixture-readback"],concurrency:{revisionField:"expectedDigest",leaseRequired:true}},handler:async params=>{ + const target=path();const previous=readFileSync(target,"utf8");if(digestJson(previous) !== params.expectedDigest)return {success:false,error:"Fixture revision changed"}; + const fixture=JSON.parse(previous) as Record;if(!fixture || typeof fixture !== "object" || Array.isArray(fixture))return {success:false,error:"Fixture must be a JSON object"}; + const next={...fixture,status:"corrected",value:1}; + // Only this fixed trusted path is selected; no shell, model-provided filenames or commands. + writeFileSync(target,`${JSON.stringify(next,null,2)}\n`);await options.afterEffect?.(); + return {success:true,data:{expectedDigest:digestJson(readFileSync(target,"utf8")),corrected:true}}; + }}; + const tools:ToolDefinition[]=[inspect,correct]; + const steps:Record={inspect:{toolName:inspect.name,action:"read",capabilityDigest:capabilityDigest(inspect),paths:()=>[options.fixturePath]},correct:{toolName:correct.name,action:"write",capabilityDigest:capabilityDigest(correct),paths:()=>[options.fixturePath],reconciliationSpecDigest:digestJson({version:1,method:"fixture-exact-file-digest",workspaceRoot:resolve(options.workspaceRoot),fixturePath:options.fixturePath}),prepareReconciliation:params=>{ + const previous=readFileSync(path(),"utf8");const fixture=JSON.parse(previous) as Record; + const next=`${JSON.stringify({...fixture,status:"corrected",value:1},null,2)}\n`; + return {expectedInputDigest:params.expectedDigest,observedInputDigest:digestJson(previous),expectedFinalDigest:digestJson(next)}; + },reconcileEffect:async context=>{ + const metadata=context.metadata; + if(metadata.expectedInputDigest !== metadata.observedInputDigest || digestJson({expectedDigest:metadata.expectedInputDigest}) !== context.inputDigest)return {effect:"unknown",sourceRefs:["fixture:input-binding-mismatch"]}; + const current=digestJson(readFileSync(scopedPath(context.workspaceRoot,options.fixturePath),"utf8")); + return {effect:current === metadata.expectedFinalDigest ? "verified":"unknown",sourceRefs:[`fixture:${options.fixturePath}#${current}`]}; + }}}; + const fixturePath=scopedPath(options.workspaceRoot,options.fixturePath);const verifierPath=scopedPath(options.workspaceRoot,options.verifierPath); + const inputs=[relative(options.workspaceRoot,fixturePath),relative(options.workspaceRoot,verifierPath)]; + const specDigest=digestJson({executable:process.execPath,argv:[verifierPath,fixturePath],environment:"local-node",inputs}); + const readback=async (workspaceRoot:string)=>{try{const fixture=JSON.parse(readFileSync(scopedPath(workspaceRoot,options.fixturePath),"utf8"));return fixture.status === "corrected" && fixture.value === 1;}catch{return false;}}; + const verificationBindings=[createCommandVerificationBinding({stepId:"fixture-check",specDigest,environment:"local-node",inputs,executable:process.execPath,argv:[verifierPath,fixturePath],timeoutMs:30000,acceptanceMethods:["fixture-readback"],readback})]; + const verificationStep:VerificationPlanStep={id:"fixture-check",kind:"command",specDigest,environment:"local-node",required:true,timeoutMs:30000}; + const contract:WorkContract={version:1,profile:"coding",actions:["read","write","test"],roots:[options.workspaceRoot],include:[inputs[0]!],exclude:[],acceptanceCriteria:[{id:"fixture-corrected",method:"fixture-readback"}]}; + return {tools,steps,verificationBindings,verificationStep,contract}; +} diff --git a/src/framework/work/tools.ts b/src/framework/work/tools.ts new file mode 100644 index 0000000..a82245c --- /dev/null +++ b/src/framework/work/tools.ts @@ -0,0 +1,22 @@ +import type { ToolDefinition } from "../types.js"; +import type { ToolExecutionContext } from "../execution-types.js"; +import type { ToolRegistry } from "../registry.js"; +import type { WorkAttemptController } from "./controller.js"; +/** Opt-in finite work facade. Inputs carry selections, never grant/lease custody. */ +export function createWorkTools(controller:Pick, registry?:ToolRegistry):ToolDefinition[] { + const definition=(name:string,description:string,properties:ToolDefinition["inputSchema"]["properties"],required:string[],handler:(params:Record,context?:ToolExecutionContext)=>Promise,readOnly=false):ToolDefinition=>({ + name,description,inputSchema:{type:"object",properties,required,additionalProperties:false},annotations:{readOnlyHint:readOnly,destructiveHint:false,openWorldHint:false},executionMode:readOnly ? "parallel-safe" : "exclusive",replayPolicy:readOnly ? "read_only" : "never",handler:async (params,context)=>{ + const invoke=async()=>{const canonical=await handler(params,context);const data=canonical && typeof canonical === "object" && "artifactProjection" in canonical && "receipt" in canonical && "outcome" in canonical ? {receipt:(canonical as {receipt:unknown}).receipt,outcome:(canonical as {outcome:unknown}).outcome,artifactProjection:(canonical as {artifactProjection:unknown}).artifactProjection} : canonical;if(data && typeof data === "object" && "receipt" in data && "outcome" in data && (data as {outcome:string}).outcome === "unknown")return {success:false,error:"Work execution is uncertain; reconcile before retry",executionOutcome:"unknown" as const,data};if(data && typeof data === "object" && "receipt" in data && "outcome" in data && (data as {outcome:string}).outcome === "failed")return {success:false,error:"Work step failed; inspect its bound execution receipt",executionOutcome:"failed" as const,data};return {success:true,data};}; + return registry ? registry.withChildExecution(invoke) : invoke(); + } + }); + return [ + definition("of_work_prepare","Inspect one selected work item and its bound authorization preview",{workItemId:{type:"string",minLength:1}},["workItemId"],(p,context)=>controller.prepare(p.workItemId as string,context),true), + definition("of_work_start","Start the explicitly selected bounded work item using current persisted grants",{workItemId:{type:"string",minLength:1}},["workItemId"],(p,context)=>controller.start(p.workItemId as string,context)), + definition("of_work_step","Run a finite granted work step",{stepKey:{type:"string",minLength:1},params:{type:"object",additionalProperties:true}},["stepKey","params"],(p,context)=>controller.step(p.stepKey as string,p.params as Record,context)), + definition("of_work_verify","Run one exact registered active-plan verification method",{stepId:{type:"string",minLength:1}},["stepId"],(p,context)=>controller.verify(p.stepId as string,context)), + definition("of_work_status","Read current local evidence and fresh backend status",{},[],(_p,context)=>controller.status(context),true), + definition("of_work_reconcile","Explicitly reconcile durable receipts and backend operation identities; never rerun work effects",{},[],(_p,context)=>controller.reconcile(context)), + definition("of_work_finish","Finalize only with current authority, subject proof and independent readback",{disposition:{type:"string",enum:["complete","review","block","release"]}},[],(p,context)=>controller.finish((p.disposition ?? "complete") as "complete"|"review"|"block"|"release",context)), + ]; +} diff --git a/src/framework/work/types.ts b/src/framework/work/types.ts new file mode 100644 index 0000000..d5dccb6 --- /dev/null +++ b/src/framework/work/types.ts @@ -0,0 +1,44 @@ +import type { ToolExecution, ToolExecutionContext } from "../execution-types.js"; +import type { ToolRegistry } from "../registry.js"; +import type { FileArtifactStore, ArtifactReference } from "../artifacts.js"; +export type WorkAction = "read"|"write"|"test"|"network"|"vault"|"deploy"|"migrate"; +export type WorkState = "prepared"|"claiming"|"running"|"verifying"|"finalizing"|"completed"|"needs_review"|"blocked"|"failed"|"released"|"reconciliation_required"; +export interface WorkContract { version:1; profile:"coding"|"read_only"; actions:WorkAction[]; roots:string[]; include:string[]; exclude:string[]; acceptanceCriteria:{id:string;method:string}[]; } +export interface WorkSnapshot { backendId:string; workItemId:string; workspaceId:string; projectId?:string; revision:string; contract:WorkContract; contractDigest:string; state:string; claimable:boolean; dependenciesSatisfied:boolean; humanReviewRequired:boolean; sourceRefs:string[]; lease?:LeaseRef; } +export interface VerificationPlanStep { id:string; kind:"command"|"readback"|"external"|"human"; specDigest:string; environment:string; required:boolean; timeoutMs:number; } +export interface VerificationPlanSnapshot { id:string; version:number; digest:string; state:"active"|"inactive"; steps:VerificationPlanStep[]; coverage:Record; } +/** Safe local lease reference. Hosted opaque custody belongs in adapter-private state. */ +export interface LeaseRef { id:string; backendId:string; workItemId:string; owner:string; expiresAt:number; } +export type OperationKind = "open"|"start"|"heartbeat"|"evidence"|"complete"|"review"|"block"|"fail"|"release"; +export interface OperationRef { id:string; kind:OperationKind; payloadDigest:string; backendKey?:string; deduplicationDeadline?:number; capabilityVersion:string; } +export interface BackendCapabilities { version:string; keyedOperations:OperationKind[]; receiptLookup:OperationKind[]; deduplicationWindowMs?:number; missingReceiptProvesNotStarted:boolean;expiredLeaseRelease?:boolean; } +export interface BackendReceipt { id:string; operationId:string; kind:OperationKind; payloadDigest:string; workItemId:string; state:string; lease?:LeaseRef; evidenceId?:string; } +export type ReconciliationResult = {state:"settled";receipt:BackendReceipt;sourceRefs:string[]}|{state:"authoritatively_not_started";sourceRefs:string[]}|{state:"unknown";sourceRefs:string[]}; +export interface WorkBackend { + readonly capabilities:BackendCapabilities; + inspect(workItemId:string,signal?:AbortSignal):Promise; + getVerificationPlan(workItemId:string,signal?:AbortSignal):Promise; + open(input:{workItemId:string;claimOwner:string;leaseSeconds:number;idempotencyKey:string},signal?:AbortSignal):Promise; + transition(lease:LeaseRef,action:Exclude,payload:Record,idempotencyKey:string,signal?:AbortSignal):Promise; + submitEvidence(lease:LeaseRef,evidence:VerificationEvidence,operation:OperationRef,signal?:AbortSignal):Promise; + reconcileOperation(operation:OperationRef,signal?:AbortSignal):Promise; + release(lease:LeaseRef,idempotencyKey:string,signal?:AbortSignal):Promise; +} +export interface WorkAuthorizationBinding {backendId:string;workItemId:string;workspaceId:string;projectId?:string;contractDigest:string;revision:string;planId:string;planVersion:number;planDigest:string;scopeDigest:string;} +export interface ResolvedWorkGrant {id:string;expiresAt:number;actions:WorkAction[];roots:string[];include:string[];exclude:string[];stepBindings:Record;acceptanceMethods:string[];limits:{maxAttempts:number;maxDurationMs:number;maxActions:number;maxOutputBytes:number};completion:boolean;} +export interface WorkGrantResolver { + resolve(binding:WorkAuthorizationBinding,purpose:"execution"|"completion"):Promise; + reserveAttempt?(binding:WorkAuthorizationBinding,attemptId:string):Promise; + reserveAction?(binding:WorkAuthorizationBinding,attemptId:string):Promise; + recordOutput?(binding:WorkAuthorizationBinding,attemptId:string,bytes:number,executionId?:string):Promise; +} +export interface WorkStepExecution extends ToolExecution {artifactProjection?:{artifact:ArtifactReference;executionId:string;receiptRef:string};} +export interface EffectReadback {effect:"verified"|"unknown";sourceRefs:string[];} +export interface WorkStepBinding {toolName:string;action:WorkAction;capabilityDigest:string;validateParams?:(params:Readonly>)=>void;paths?:(params:Readonly>)=>string[];reconciliationSpecDigest?:string;prepareReconciliation?:(params:Readonly>,workspaceRoot:string)=>Record;reconcileEffect?:(context:{workspaceRoot:string;metadata:Readonly>;inputDigest:string;capabilityDigest:string;signal:AbortSignal})=>Promise;} +export interface VerificationSubject {workspaceId:string;inputManifest:readonly string[];digest:string;mutationEpoch:number;} +export interface VerificationEnvironmentSnapshot {platform:string;arch:string;hostNodeVersion:string;executable:string;cwd:string;environmentDigest:string;} +export interface VerificationEvidence {id:string;backendId:string;workItemId:string;attemptId:string;planId:string;planVersion:number;stepId:string;specDigest:string;environment:string;environmentSnapshot?:VerificationEnvironmentSnapshot;before:VerificationSubject;after:VerificationSubject;startedAt:number;finishedAt:number;exitCode:number|null;outcome:"passed"|"failed"|"inconclusive";readbackPassed:boolean;acceptanceMethods:string[];artifact:ArtifactReference;} +export interface VerificationRun {environmentSnapshot?:VerificationEnvironmentSnapshot;exitCode:number|null;output:string;readbackPassed:boolean;acceptanceMethods:string[];} +export interface VerificationBinding {stepId:string;specDigest:string;environment:string;inputs:readonly string[];run:(context:{workspaceRoot:string;signal:AbortSignal})=>Promise;readback:(workspaceRoot:string)=>Promise;} +export interface WorkPreview {binding:WorkAuthorizationBinding;snapshot:WorkSnapshot;plan:VerificationPlanSnapshot;steps:Record;createdAt:number;digest:string;} +export interface WorkControllerOptions {backend:WorkBackend;registry:ToolRegistry;grantResolver:WorkGrantResolver;journalRoot:string;artifactRoot:string;workspaceRoot:string;claimOwner:string;steps:Record;verificationBindings:readonly VerificationBinding[];clock?:()=>number;leaseSeconds?:number;attemptId?:string;renewalSafetyMs?:number;renewalRunner?:(call:()=>Promise,context:ToolExecutionContext)=>Promise;artifactStore?:FileArtifactStore;} diff --git a/src/framework/work/verification.ts b/src/framework/work/verification.ts new file mode 100644 index 0000000..cc15cac --- /dev/null +++ b/src/framework/work/verification.ts @@ -0,0 +1,70 @@ +/** Domain-bound verification, shell-free commands and explicit subject fingerprints. */ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstatSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { digestJson, snapshotJson } from "../session.js"; +import { JournalInvariantError } from "../journal-file.js"; +import type { VerificationBinding, VerificationSubject } from "./types.js"; + +/** Reject every symlink component, including a symlink supplied as the root. */ +export function scopedPath(workspaceRoot:string,path:string,allowMissingFinal=false):string { + const root=resolve(workspaceRoot);const target=isAbsolute(path) ? resolve(path) : resolve(root,path); + const rel=relative(root,target);if(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))throw new JournalInvariantError("Path escapes authorized workspace"); + let current=root;const rootParts=root.split(sep).filter(Boolean);current=sep; + for(const part of rootParts){current=join(current,part);const stats=lstatSync(current);if(stats.isSymbolicLink() || !stats.isDirectory())throw new JournalInvariantError("Workspace root has unsafe path component");} + const parts=rel.split(sep).filter(Boolean); + for(let index=0;index; + try{stats=lstatSync(current);}catch(error){if(allowMissingFinal && index === parts.length-1 && (error as NodeJS.ErrnoException).code === "ENOENT")return target;throw error;} + if(stats.isSymbolicLink() || index{ + const path=scopedPath(workspaceRoot,input);const stats=lstatSync(path); + if(stats.isDirectory()){for(const name of readdirSync(path).sort())visit(join(path,name));return;} + if(!stats.isFile())throw new JournalInvariantError("Verification inputs must be ordinary files"); + const bytes=readFileSync(path);const after=lstatSync(path); + if(stats.ino !== after.ino || stats.size !== after.size || stats.mtimeMs !== after.mtimeMs || stats.ctimeMs !== after.ctimeMs)throw new JournalInvariantError("Verification input changed during fingerprinting"); + files.push({path:relative(resolve(workspaceRoot),path),sha256:createHash("sha256").update(bytes).digest("hex"),mode:stats.mode,size:bytes.length}); + }; + for(const input of [...inputs].sort())visit(input); + return {workspaceId,inputManifest:files.map(file=>file.path),digest:digestJson(files),mutationEpoch}; +} +export interface CommandBinding { + stepId:string;specDigest:string;environment:string;inputs:readonly string[]; + executable:string;argv:readonly string[];cwd?:string;timeoutMs:number; + environmentVariables?:Readonly>; + outputLimitBytes?:number;acceptanceMethods:readonly string[]; + readback:(workspaceRoot:string)=>Promise; +} +export function createCommandVerificationBinding(original:CommandBinding):VerificationBinding { + const {readback,...configuration}=original;const binding={...snapshotJson(configuration),readback}; + if(!binding.executable || !binding.specDigest || !Number.isFinite(binding.timeoutMs) || binding.timeoutMs <= 0)throw new JournalInvariantError("Invalid trusted command binding"); + return {stepId:binding.stepId,specDigest:binding.specDigest,environment:binding.environment,inputs:[...binding.inputs],readback:binding.readback,run:async ({workspaceRoot,signal})=>{ + const cwd=scopedPath(workspaceRoot,binding.cwd ?? "."); + // The bound executable/argv are trusted integration configuration, never model parameters. + const environment={...(process.env.PATH ? {PATH:process.env.PATH}:{}),...(process.env.SystemRoot ? {SystemRoot:process.env.SystemRoot}:{}),LANG:"C.UTF-8",...binding.environmentVariables}; + const environmentSnapshot={platform:process.platform,arch:process.arch,hostNodeVersion:process.version,executable:binding.executable,cwd,environmentDigest:digestJson(environment)}; + const result=await new Promise<{exitCode:number|null;output:string}>((resolveResult)=>{ + const child=spawn(binding.executable,[...binding.argv],{cwd,shell:false,env:environment,stdio:["ignore","pipe","pipe"]}); + const chunks:Buffer[]=[];let total=0;let exceeded=false;let timedOut=false; + const collect=(value:Buffer)=>{total+=value.length;if(total > (binding.outputLimitBytes ?? 16*1024*1024)){exceeded=true;child.kill("SIGKILL");return;}chunks.push(Buffer.from(value));}; + child.stdout.on("data",collect);child.stderr.on("data",collect); + const timer=setTimeout(()=>{timedOut=true;child.kill("SIGKILL");},binding.timeoutMs); + const cancel=()=>child.kill("SIGKILL");signal.addEventListener("abort",cancel,{once:true});if(signal.aborted)cancel(); + child.once("error",error=>{clearTimeout(timer);signal.removeEventListener("abort",cancel);resolveResult({exitCode:null,output:error.message});}); + child.once("close",code=>{clearTimeout(timer);signal.removeEventListener("abort",cancel);resolveResult({exitCode:exceeded || timedOut || signal.aborted ? null : code,output:Buffer.concat(chunks).toString("utf8")+(exceeded ? "\nOutput quota exceeded" : timedOut ? "\nVerification timed out" : signal.aborted ? "\nVerification interrupted" : "")});}); + }); + const readbackPassed=await binding.readback(workspaceRoot); + return {...result,environmentSnapshot,readbackPassed,acceptanceMethods:readbackPassed ? [...binding.acceptanceMethods] : []}; + }}; +} +export function pathMatches(path:string,patterns:readonly string[]):boolean { + return patterns.some(pattern=>new RegExp(`^${pattern.split("**").map(part=>part.split("*").map(piece=>piece.replace(/[|\\{}()[\]^$+?.]/g,"\\$&")).join("[^/]*")).join(".*")}$`).test(path.replaceAll(sep,"/"))); +} diff --git a/src/framework/workflows.ts b/src/framework/workflows.ts index e4d6421..46fe5d1 100644 --- a/src/framework/workflows.ts +++ b/src/framework/workflows.ts @@ -18,6 +18,7 @@ */ import type { ToolResult } from "./types.js"; +import type { ToolExecutionContext } from "./execution-types.js"; import type { ToolRegistry } from "./registry.js"; import type { AIAdapter, @@ -25,7 +26,7 @@ import type { ChatMessage, } from "./adapters/types.js"; import { validatedAdapterToolCalls } from "./adapters/types.js"; -import { normalizeToolResult, uncertainToolExecution } from "./tool.js"; +import { uncertainToolExecution } from "./tool.js"; import { completeRun, createRunManifest, @@ -173,8 +174,9 @@ function createWorkflow( export function toolStep( registry: ToolRegistry, toolName: string, + executionContext?: ToolExecutionContext, ): Step, ToolResult> { - return async (params) => registry.execute(toolName, params); + return async (params) => (await registry.executeDetailed(toolName, params, executionContext)).result; } function attachWorkflowToolEffects( @@ -186,6 +188,8 @@ function attachWorkflowToolEffects( name: receipt.name, args: { ...receipt.args }, result: { ...receipt.result }, + ...(receipt.executionReceiptId === undefined ? {} : { executionReceiptId: receipt.executionReceiptId }), + ...(receipt.verificationRefs === undefined ? {} : { verificationRefs: [...receipt.verificationRefs] }), })); const certainty = copiedReceipts.some( (receipt) => receipt.result.executionOutcome === "unknown", @@ -219,6 +223,7 @@ export function llmStep( adapter: AIAdapter, registry: ToolRegistry, promptTemplate: string, + executionContext?: ToolExecutionContext, ): Step { return async (input: string) => { const prompt = promptTemplate.replace(/\{\{input\}\}/g, input); @@ -270,20 +275,26 @@ export function llmStep( }); const settledResults = await Promise.allSettled( - calls.map((call) => registry.execute(call.name, call.args)), + calls.map((call) => registry.executeDetailed(call.name, call.args, { + ...executionContext, callId: call.id, runId: run.manifest.runId, correlationId: run.manifest.correlationId, + actor: executionContext?.actor ?? { kind: "agent", id: "workflow_llm_step" }, + })), ); - const executions = settledResults.map((settled, index) => + const executions = settledResults.map((settled) => settled.status === "rejected" ? uncertainToolExecution(settled.reason) - : normalizeToolResult(calls[index].name, settled.value) + : settled.value ); for (let index = 0; index < calls.length; index += 1) { const call = calls[index]; const execution = executions[index]; + const settled = settledResults[index]; + const receipt = settled.status === "fulfilled" ? settled.value.receipt : undefined; const result = execution.result; receipts.push({ name: call.name, args: { ...call.args }, + ...(receipt ? { executionReceiptId: receipt.executionId, verificationRefs: receipt.verification.evidenceRefs } : {}), result: { success: result.success, ...(Object.prototype.hasOwnProperty.call(result, "data") diff --git a/src/index.ts b/src/index.ts index bce6175..78662a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,2 @@ -// Load .env file if it exists -import "./framework/env.js"; - -/** - * OpenFunction — Entry Point - * - * Registers all tools and starts the MCP server. - * To add your own tools, edit src/my-tools/index.ts. - */ - -import { startServer } from "./framework/index.js"; -import { registry } from "./register-tools.js"; - -startServer(registry, { - name: "openfunction", - version: "1.0.0", -}); +/** Public library entry. Importing the library does not start a server. */ +export * from "./framework/index.js"; diff --git a/src/providers/execufunction/client.ts b/src/providers/execufunction/client.ts index e3d3bdb..ee6cec7 100644 --- a/src/providers/execufunction/client.ts +++ b/src/providers/execufunction/client.ts @@ -244,18 +244,14 @@ export class ExfClient { // ─── Codebase ─────────────────────────────────────────────────────────── - async searchCode(query: string, repositoryId?: string) { - return unwrap( - await this.sift.searchCode({ query, repositoryId }), - "searchCode", - ); + /** @deprecated Hosted source indexing/search has been retired by Siftable. */ + async searchCode(_query: string, _repositoryId?: string): Promise<{ results: Record[] }> { + throw new Error("Siftable hosted source search is retired. Use local rg or git grep in an authorized checkout; curated code_memory tools remain available."); } - async codeWhoKnows(repositoryId: string, area: string) { - return unwrap( - await this.sift.whoKnows(repositoryId, area), - "whoKnows", - ); + /** @deprecated Hosted source ownership lookup has been retired by Siftable. */ + async codeWhoKnows(_repositoryId: string, _area: string): Promise { + throw new Error("Siftable hosted source ownership lookup is retired. Use authorized local repository history instead."); } // ─── Health ───────────────────────────────────────────────────────────── diff --git a/src/providers/execufunction/index.ts b/src/providers/execufunction/index.ts index d84129a..d70fde3 100644 --- a/src/providers/execufunction/index.ts +++ b/src/providers/execufunction/index.ts @@ -79,7 +79,6 @@ const METADATA: ContextProviderMetadata = { "knowledge", "people", "organizations", - "codebase", "code_memory", "vault", "datasets", diff --git a/src/providers/execufunction/sdk-tools.ts b/src/providers/execufunction/sdk-tools.ts index 7604de9..c040ef1 100644 --- a/src/providers/execufunction/sdk-tools.ts +++ b/src/providers/execufunction/sdk-tools.ts @@ -11,13 +11,15 @@ import { type McpTransportProfile, } from "@siftable/mcp-server/factory"; import type { InputSchema, ToolDefinition } from "../../framework/types.js"; -import { defineTool, err, ok } from "../../framework/tool.js"; +import { defineTool, ok } from "../../framework/tool.js"; import type { ExfClient } from "./client.js"; type SiftableSdkTool = { name: string; description: string; inputSchema: unknown; + annotations?: ToolDefinition["annotations"]; + outputSchema?: ToolDefinition["outputSchema"]; }; export interface SiftableSdkToolOptions { @@ -31,6 +33,46 @@ function asInputSchema(schema: unknown): InputSchema { return structuredClone(schema) as InputSchema; } +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** executeTool emits the receipt's structured value, while MCP factory adds its envelope. */ +function outputProjection(schema: unknown): { schema?: InputSchema; limitation?: string; upstream?: unknown } { + if (schema === undefined) return {}; + if (!record(schema) || schema.type !== "object") { + return { upstream: structuredClone(schema), limitation: "The upstream output schema has no supported object root; the embedded projection retains it as provenance without advertising an incompatible schema." }; + } + if (!record(schema.properties) || !record(schema.properties.kind) || + schema.properties.kind.const !== "sift.action_receipt") return { schema: asInputSchema(schema) }; + const upstream = structuredClone(schema); + const result = schema.properties.result; + const variants = record(result) && Array.isArray(result.anyOf) ? result.anyOf : []; + const values: unknown[] = []; + for (const variant of variants) { + if (record(variant) && record(variant.properties) && record(variant.properties.contentType) && + variant.properties.contentType.const === "application/json") values.push(variant.properties.value); + } + const supportedReferences = (value: unknown): boolean => { + if (Array.isArray(value)) return value.every(supportedReferences); + if (!record(value)) return true; + if (typeof value.$ref === "string" && !/^#\/(?:\$defs|definitions)(?:\/|$)/.test(value.$ref)) return false; + return Object.values(value).every(supportedReferences); + }; + if (!values.length || values.some((value) => !record(value) || value.type !== "object" || !supportedReferences(value)) || + !supportedReferences(schema.$defs) || !supportedReferences(schema.definitions)) { + return { upstream, limitation: "Embedded executeTool does not expose a faithfully extractable object payload schema. The upstream MCP receipt schema is retained as provenance; no output schema is advertised." }; + } + const projected: Record = values.length === 1 + ? structuredClone(values[0]) as Record + : { type: "object", properties: {}, anyOf: structuredClone(values) }; + for (const keyword of ["$defs", "definitions", "$schema"]) { + if (schema[keyword] !== undefined) projected[keyword] = structuredClone(schema[keyword]); + } + projected.properties ??= {}; + return { schema: projected as unknown as InputSchema, upstream }; +} + function domainFor(name: string): string { if (name.startsWith("work_item_") || name.startsWith("work_dependency_")) return "agent_work"; if (name.startsWith("code_memory_")) return "code_memory"; @@ -50,7 +92,7 @@ function executionFailure(text: string): string | undefined { * are evaluated by the installed @siftable/mcp-server package. */ export function createSiftableSdkTools( - client: ExfClient, + client: Pick, options: SiftableSdkToolOptions = {}, ): ToolDefinition, unknown>[] { const transportProfile = options.transportProfile ?? "hosted_remote"; @@ -59,28 +101,67 @@ export function createSiftableSdkTools( return tools .filter((tool) => options.includeDisabled || isToolEnabled(tool.name)) .filter((tool) => isToolAllowedForTransport(tool.name, transportProfile)) - .map((tool) => defineTool, unknown>({ - name: tool.name, - description: tool.description, - inputSchema: asInputSchema(tool.inputSchema), - tags: ["siftable", `siftable:${domainFor(tool.name)}`, "siftable:mcp"], - handler: async (params) => { - let metadata: ToolExecutionMetadata | undefined; - const text = await executeTool(client.raw(), tool.name, params, { - transportProfile, - onExecutionMetadata: (value) => { metadata = value; }, - }); - const failure = executionFailure(text); - if (failure) return err(failure); + // Embedded clients do not own an authenticated principal/session store. + // Hosted sessions are available through createSiftableMcpProvider instead. + .filter((tool) => !tool.name.startsWith("work_session_")) + .map((tool) => { + const projected = outputProjection(tool.outputSchema); + const provenance = projected.upstream === undefined ? undefined : { + "siftable/upstreamOutputSchema": projected.upstream, + "siftable/outputProjection": projected.limitation ?? "Embedded executeTool structured value; upstream MCP factory receipt envelope remains a separate transport contract", + }; + return defineTool, unknown>({ + name: tool.name, + description: projected.limitation ? `${tool.description} ${projected.limitation}` : tool.description, + inputSchema: asInputSchema(tool.inputSchema), + ...(tool.annotations === undefined ? {} : { annotations: structuredClone(tool.annotations) }), + ...(projected.schema === undefined ? {} : { outputSchema: projected.schema }), + ...(provenance ? { _meta: provenance } : {}), + tags: ["siftable", `siftable:${domainFor(tool.name)}`, "siftable:mcp"], + handler: async (params, context) => { + let metadata: ToolExecutionMetadata = {}; + const executionContext = { + transportProfile, + signal: context?.signal, + onExecutionMetadata: (value: ToolExecutionMetadata) => { + metadata = { + ...metadata, + ...Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)), + }; + }, + }; + const text = await executeTool(client.raw(), tool.name, params, executionContext); + const upstream = Object.keys(metadata).length > 0 + ? { "siftable/execution": metadata } + : undefined; + const failure = executionFailure(text); + if (failure) { + // A formatted SDK error does not prove a write was never applied. + // SDK discovery hints also cannot establish authoritative certainty. + return { + success: false, + error: failure, + executionOutcome: "unknown", + ...(metadata.structuredResult ? { structuredContent: metadata.structuredResult.value } : {}), + ...(upstream ? { _meta: upstream } : {}), + }; + } - if (metadata?.structuredResult) { - return ok(metadata.structuredResult.value, text); - } - try { - return ok(JSON.parse(text) as unknown); - } catch { - return ok(text); - } - }, - })); + if (metadata.structuredResult) { + return { + ...ok(metadata.structuredResult.value, text), + structuredContent: metadata.structuredResult.value, + _meta: upstream, + }; + } + let data: unknown; + try { + data = JSON.parse(text) as unknown; + } catch { + data = text; + } + return { ...ok(data), ...(upstream ? { _meta: upstream } : {}) }; + }, + }); + }); } diff --git a/src/providers/execufunction/tools.ts b/src/providers/execufunction/tools.ts index 01d5867..c8da35d 100644 --- a/src/providers/execufunction/tools.ts +++ b/src/providers/execufunction/tools.ts @@ -6,6 +6,7 @@ * agent filtering. */ +import { generateIdempotencyKey } from "@siftable/mcp-server/exfClient"; import type { ToolDefinition } from "../../framework/types.js"; import { defineTool, ok, err } from "../../framework/tool.js"; import type { ExfClient } from "./client.js"; @@ -398,47 +399,9 @@ export function createPeopleTools(client: ExfClient): ToolDefinition[] // ─── Codebase ─────────────────────────────────────────────────────────────── -export function createCodebaseTools(client: ExfClient): ToolDefinition[] { - return [ - defineTool<{ query: string; repositoryId?: string }>({ - name: "exf_codebase_search", - description: - "Semantic search across indexed codebases in Siftable. " + - "Finds relevant code snippets, files, and functions by meaning.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "Search query (natural language)" }, - repositoryId: { type: "string", description: "Limit to a specific repository" }, - }, - required: ["query"], - }, - tags: ["codebase"], - handler: async ({ query, repositoryId }) => { - const data = await client.searchCode(query, repositoryId); - return ok(data, `Found ${data.results.length} result(s)`); - }, - }), - - defineTool<{ repositoryId: string; area: string }>({ - name: "exf_code_who_knows", - description: - "Find who has expertise in a specific code area, based on commit history and ownership.", - inputSchema: { - type: "object", - properties: { - repositoryId: { type: "string", description: "Repository ID" }, - area: { type: "string", description: "Code area or topic, e.g. 'auth middleware'" }, - }, - required: ["repositoryId", "area"], - }, - tags: ["codebase"], - handler: async ({ repositoryId, area }) => { - const data = await client.codeWhoKnows(repositoryId, area); - return ok(data); - }, - }), - ]; +/** Retained factory name; retired remote source capabilities are not advertised. */ +export function createCodebaseTools(_client: ExfClient): ToolDefinition[] { + return []; } // ─── Work Items ───────────────────────────────────────────────────────────── @@ -531,6 +494,7 @@ export function createWorkItemTools(client: ExfClient): ToolDefinition claimOwner: string; workItemId?: string; leaseSeconds?: number; + idempotencyKey?: string; }>({ name: "exf_work_item_claim", description: @@ -549,22 +513,24 @@ export function createWorkItemTools(client: ExfClient): ToolDefinition }, workItemId: { type: "string", description: "Optional specific work-item UUID" }, leaseSeconds: { type: "integer", description: "Claim lease duration in seconds" }, + idempotencyKey: { type: "string", description: "Stable key for an exact retry; omitted keys are fresh per invocation" }, }, required: ["claimOwner"], }, tags: ["work_items"], - handler: async ({ alias, claimOwner, workItemId, leaseSeconds }) => { + handler: async ({ alias, claimOwner, workItemId, leaseSeconds, idempotencyKey }, context) => { const input = { claimOwner, ...(alias ? { assignedAlias: alias } : {}), ...(workItemId ? { workItemId } : {}), ...(leaseSeconds ? { leaseSeconds } : {}), }; - const res = await sift.claimWorkItem(input); + const key = idempotencyKey ?? context?.idempotencyKey ?? generateIdempotencyKey("openfunction-legacy-claim"); + const res = await sift.claimWorkItem(input, key); if (res.error || !res.data) { - return err(`claimWorkItem failed (${res.statusCode}): ${res.error}`); + return { ...err(`claimWorkItem failed (${res.statusCode}): ${res.error}`), executionOutcome: "unknown" }; } - return ok(res.data, `Claimed work item for ${alias ?? claimOwner}`); + return { ...ok(res.data, `Claimed work item for ${alias ?? claimOwner}`), _meta: { "siftable/idempotency": { key, ...res.idempotency } } }; }, }), @@ -652,7 +618,7 @@ export function createWorkItemTools(client: ExfClient): ToolDefinition } const res = await sift.transitionWorkItem(workItemId, action, input); if (res.error || !res.data) { - return err(`transitionWorkItem failed (${res.statusCode}): ${res.error}`); + return { ...err(`transitionWorkItem failed (${res.statusCode}): ${res.error}`), executionOutcome: "unknown" }; } return ok(redactWorkItemTokens(res.data), `Work item transitioned: ${action}`); }, @@ -726,6 +692,7 @@ export function createVaultTools(client: ExfClient): ToolDefinition[] tags?: string[]; category?: string; url?: string; + idempotencyKey?: string; }>({ name: "exf_vault_create", description: @@ -746,16 +713,19 @@ export function createVaultTools(client: ExfClient): ToolDefinition[] tags: { type: "array", items: { type: "string" }, description: "Tags" }, category: { type: "string", description: "Category for grouping" }, url: { type: "string", description: "Associated URL" }, + idempotencyKey: { type: "string", description: "Stable key for an exact retry; omitted keys are fresh per invocation" }, }, required: ["name", "payload"], }, tags: ["vault"], - handler: async (params) => { - const res = await sift.createVaultEntry(params); + handler: async (params, context) => { + const { idempotencyKey, ...payload } = params; + const key = idempotencyKey ?? context?.idempotencyKey ?? generateIdempotencyKey("openfunction-legacy-vault"); + const res = await sift.createVaultEntry(payload, key); if (res.error || !res.data) { - return err(`createVaultEntry failed (${res.statusCode}): ${res.error}`); + return { ...err(`createVaultEntry failed (${res.statusCode}): ${res.error}`), executionOutcome: "unknown" }; } - return ok(res.data, `Created vault entry: ${params.name}`); + return { ...ok(res.data, `Created vault entry: ${params.name}`), _meta: { "siftable/idempotency": { key, ...res.idempotency } } }; }, }), ]; @@ -874,6 +844,7 @@ export function createCodeMemoryTools(client: ExfClient): ToolDefinition({ name: "exf_code_memory_store", description: @@ -888,16 +859,19 @@ export function createCodeMemoryTools(client: ExfClient): ToolDefinition { - const res = await sift.storeCodeMemory(params); + handler: async (params, context) => { + const { idempotencyKey, ...payload } = params; + const key = idempotencyKey ?? context?.idempotencyKey ?? generateIdempotencyKey("openfunction-legacy-memory"); + const res = await sift.storeCodeMemory(payload, key); if (res.error || !res.data) { - return err(`storeCodeMemory failed (${res.statusCode}): ${res.error}`); + return { ...err(`storeCodeMemory failed (${res.statusCode}): ${res.error}`), executionOutcome: "unknown" }; } - return ok(res.data, "Code memory stored"); + return { ...ok(res.data, "Code memory stored"), _meta: { "siftable/idempotency": { key, ...res.idempotency } } }; }, }), @@ -954,7 +928,7 @@ export function createCodeMemoryTools(client: ExfClient): ToolDefinition { const res = await sift.deleteCodeMemory(memoryId); if (res.error || !res.data) { - return err(`deleteCodeMemory failed (${res.statusCode}): ${res.error}`); + return { ...err(`deleteCodeMemory failed (${res.statusCode}): ${res.error}`), executionOutcome: "unknown" }; } return ok(res.data, "Code memory deleted"); }, diff --git a/src/providers/mcp/index.ts b/src/providers/mcp/index.ts new file mode 100644 index 0000000..cf31bd8 --- /dev/null +++ b/src/providers/mcp/index.ts @@ -0,0 +1,309 @@ +/** A bounded MCP client projected into the ordinary OpenFunction tool surface. */ +import { createHash } from "node:crypto"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport, FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { + CallToolResultSchema, + McpError, + ErrorCode, + ToolListChangedNotificationSchema, + type CallToolResult, + type Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import type { ContextCapability, ContextProvider, ContextProviderMetadata, ConnectedProvider } from "../../framework/context.js"; +import type { ToolExecutionContext } from "../../framework/execution-types.js"; +import type { InputSchema, ToolDefinition, ToolResult } from "../../framework/types.js"; + +export type McpProviderTransport = { + kind: "stdio"; + command: string; + args?: readonly string[]; + cwd?: string; + /** Trusted host callback; credentials never become tool arguments. */ + resolveEnvironment?: () => Record | Promise>; +} | { + kind: "streamable_http"; + url: string | URL; + /** Trusted host callback, evaluated per HTTP request. */ + resolveHeaders?: () => HeadersInit | Promise; +}; + +export interface McpProviderOptions { + id: string; + name?: string; + description?: string; + capabilities?: ContextCapability[]; + transport?: McpProviderTransport; + /** Trusted custom transport factory; each connection owns the returned transport. */ + createTransport?: () => Transport | Promise; + fetch?: FetchLike; + timeoutMs?: number; + maxCatalogPages?: number; + toolAllowlist?: readonly string[]; + /** Trusted approval/filtering at connection or an explicit refresh boundary. */ + approveCatalog?: (catalog: readonly Tool[]) => readonly string[] | Promise; + /** Authoritative provider-specific rejection, never a text-prefix heuristic. */ + isAuthoritativeRejection?: (result: CallToolResult, toolName: string) => boolean; +} + +export interface McpCatalogSnapshot { + digest: string; + generation: number; + tools: readonly Tool[]; +} + +export interface McpProvider extends Omit { + connect(config?: Record): Promise; +} + +export interface ConnectedMcpProvider extends ConnectedProvider { + readonly catalog: McpCatalogSnapshot; + readonly catalogStale: boolean; + /** Refuse concurrent refresh; old tool projections remain invalid afterward. */ + refreshCatalog(): Promise; + disconnect(): Promise; +} + +/** Default network boundary: HTTPS or an explicit loopback HTTP endpoint. */ +export function validateMcpEndpoint(value: string | URL): URL { + const url = new URL(String(value)); + const host = url.hostname.toLowerCase(); + const loopback = host === "localhost" || host === "[::1]" || + /^127(?:\.(?:\d{1,3})){3}$/.test(host); + if (url.username || url.password) throw new Error("MCP endpoint credentials must come from a trusted auth callback"); + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("MCP endpoints require HTTPS; HTTP is allowed only for loopback testing"); + } + return url; +} + +function freezeJson(value: T): T { + if (value && typeof value === "object") { + for (const child of Object.values(value)) freezeJson(child); + Object.freeze(value); + } + return value; +} + +function safeError(error: unknown): string { + return error instanceof Error ? error.message : "MCP transport failed"; +} + +/** Redirects cannot replay a call or carry credentials to another origin. */ +function scopedFetch(endpoint: URL, options: McpProviderOptions, config: Extract): FetchLike { + const fetcher = options.fetch ?? globalThis.fetch; + return async (input, init) => { + let target = new URL(input instanceof Request ? input.url : String(input)); + const requestInit: RequestInit = { ...init, redirect: "manual" }; + if (target.origin !== endpoint.origin) throw new Error("MCP authentication is scoped to the configured endpoint origin"); + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); + new Headers(await config.resolveHeaders?.()).forEach((value, key) => headers.set(key, value)); + requestInit.headers = headers; + for (let redirects = 0; redirects < 4; redirects++) { + const response = await fetcher(target, requestInit); + if (![301, 302, 303, 307, 308].includes(response.status)) return response; + const location = response.headers.get("location"); + if (!location) return response; + const next = validateMcpEndpoint(new URL(location, target)); + // Do not even issue an unauthenticated request to a different origin. + if (next.origin !== endpoint.origin) throw new Error("MCP cross-origin redirect refused; configure the final authenticated endpoint"); + if ((requestInit.method ?? "GET").toUpperCase() !== "GET") { + throw new Error("MCP redirected a request; configure the final endpoint before dispatching tools"); + } + target = next; + } + throw new Error("MCP endpoint exceeded the redirect limit"); + }; +} + +async function providerTransport(options: McpProviderOptions): Promise { + if (options.createTransport) return options.createTransport(); + const config = options.transport; + if (!config) throw new Error("MCP provider requires a transport configuration"); + if (config.kind === "stdio") { + return new StdioClientTransport({ + command: config.command, + args: config.args ? [...config.args] : undefined, + cwd: config.cwd, + env: { ...getDefaultEnvironment(), ...await config.resolveEnvironment?.() }, + stderr: "inherit", + }); + } + const endpoint = validateMcpEndpoint(config.url); + return new StreamableHTTPClientTransport(endpoint, { + fetch: scopedFetch(endpoint, options, config), + reconnectionOptions: { + maxRetries: 0, maxReconnectionDelay: 1000, + initialReconnectionDelay: 1000, reconnectionDelayGrowFactor: 1, + }, + }); +} + +function requestOptions(context: ToolExecutionContext | undefined, timeoutMs: number) { + return { + ...(context?.signal ? { signal: context.signal } : {}), + timeout: context?.deadline === undefined + ? timeoutMs + : Math.max(1, Math.min(timeoutMs, context.deadline - Date.now())), + }; +} + +export function createMcpProvider(options: McpProviderOptions): McpProvider { + if (!/^[a-z][a-z0-9_-]*$/.test(options.id)) throw new Error("MCP provider id must be a stable lowercase identifier"); + const timeoutMs = options.timeoutMs ?? 30000; + const maxPages = options.maxCatalogPages ?? 100; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxPages) || maxPages < 1) { + throw new Error("MCP timeout and catalog page limit must be positive"); + } + const metadata: ContextProviderMetadata = { + id: options.id, + name: options.name ?? options.id, + description: options.description ?? "Tools from an MCP connection", + capabilities: options.capabilities ?? [], + }; + return { + metadata, + async connect(): Promise { + const client = new Client({ name: "openfunction", version: "1.0.0" }, { capabilities: {} }); + // No sampling/elicitation capabilities are advertised. The protocol's + // default handler rejects unsupported methods; no fabricated approvals. + let connected = false; + let stale = false; + let activeCalls = 0; + let refreshing = false; + let invalidations = 0; + let generation = 0; + let catalog: McpCatalogSnapshot; + client.fallbackRequestHandler = async (request) => { + throw new McpError(ErrorCode.MethodNotFound, + `Upstream ${request.method} requires a trusted host callback. No approval or sampled response was supplied; resume through a host that supports this request.`); + }; + client.onclose = () => { connected = false; stale = true; invalidations++; }; + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { stale = true; invalidations++; }); + + async function discover(): Promise { + const discovered: Tool[] = []; + const cursors = new Set(); + const names = new Set(); + let cursor: string | undefined; + for (let page = 0; page < maxPages; page++) { + const response = await client.listTools(cursor === undefined ? {} : { cursor }, { timeout: timeoutMs }); + for (const tool of response.tools) { + if (names.has(tool.name)) throw new Error(`MCP catalog repeated tool: ${tool.name}`); + names.add(tool.name); + discovered.push(structuredClone(tool)); + } + cursor = response.nextCursor; + if (cursor === undefined) break; + if (cursors.has(cursor)) throw new Error("MCP catalog repeated its pagination cursor"); + cursors.add(cursor); + if (page === maxPages - 1) throw new Error("MCP catalog exceeded its page limit"); + } + const immutable = freezeJson(discovered); + let allowed = options.toolAllowlist ? new Set(options.toolAllowlist) : names; + if (options.approveCatalog) { + const approved = new Set(await options.approveCatalog(immutable)); + allowed = new Set([...allowed].filter((name) => approved.has(name))); + for (const name of approved) if (!names.has(name)) throw new Error(`MCP approval named an unavailable tool: ${name}`); + } + if (options.toolAllowlist) { + for (const name of options.toolAllowlist) if (!names.has(name)) throw new Error(`MCP allowlist tool is unavailable: ${name}`); + } + const tools = freezeJson(discovered.filter((tool) => allowed.has(tool.name))); + generation++; + return freezeJson({ + generation, tools, + digest: createHash("sha256").update(JSON.stringify(tools)).digest("hex"), + }); + } + const transport = await providerTransport(options); + try { + await client.connect(transport, { timeout: timeoutMs }); + connected = true; + catalog = await discover(); + } catch (error) { + await client.close().catch(() => undefined); + throw error; + } + return { + metadata, + get catalog() { return catalog; }, + get catalogStale() { return stale; }, + createTools(): ToolDefinition[] { + const snapshot = catalog; + return snapshot.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? tool.title ?? `Call upstream tool ${tool.name}`, + inputSchema: tool.inputSchema as InputSchema, + ...(tool.annotations ? { annotations: tool.annotations } : {}), + ...(tool.outputSchema ? { outputSchema: tool.outputSchema as InputSchema } : {}), + ...(tool._meta ? { _meta: tool._meta } : {}), + tags: ["context", `context:${options.id}`, "mcp"], + // Discovery hints do not authorize effects or prove retry safety. + replayPolicy: "never", + handler: async (params, context): Promise => { + if (!connected || stale || snapshot.generation !== catalog.generation) { + return { + success: false, executionOutcome: "failed", + error: "MCP catalog is disconnected or stale. Reconnect or explicitly refresh at a safe session boundary before dispatch.", + }; + } + if (context?.signal?.aborted || (context?.deadline !== undefined && context.deadline <= Date.now())) { + return { success: false, executionOutcome: "failed", error: "MCP call cancelled before upstream dispatch" }; + } + activeCalls++; + try { + const result = CallToolResultSchema.parse(await client.callTool({ name: tool.name, arguments: params }, CallToolResultSchema, requestOptions(context, timeoutMs))); + const errorText = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); + return { + success: !result.isError, + executionOutcome: result.isError + ? options.isAuthoritativeRejection?.(result, tool.name) ? "failed" : "unknown" + : "succeeded", + ...(result.isError ? { error: errorText || "Upstream MCP tool reported an error; reconcile its effect before retrying" } : {}), + content: result.content, + ...(result.structuredContent === undefined ? {} : { + data: result.structuredContent, structuredContent: result.structuredContent, + }), + ...(result._meta === undefined ? {} : { _meta: result._meta }), + }; + } catch (error) { + return { + success: false, executionOutcome: "unknown", + error: `Upstream MCP outcome is unknown: ${safeError(error)}. Reconcile the effect before retrying; no call was automatically retried.`, + }; + } finally { + activeCalls--; + } + }, + })); + }, + async refreshCatalog() { + if (!connected) throw new Error("MCP provider is disconnected; create a new connection"); + if (activeCalls || refreshing) throw new Error("MCP catalog refresh requires a boundary with no active calls or refresh"); + stale = true; + refreshing = true; + const before = invalidations; + try { + catalog = await discover(); + stale = invalidations !== before; + return catalog; + } finally { + refreshing = false; + } + }, + async healthCheck() { + return { ok: connected && !stale, ...(!connected || stale ? { error: "MCP connection is disconnected or catalog needs refresh" } : {}) }; + }, + async disconnect() { + connected = false; + stale = true; + await client.close(); + }, + }; + }, + }; +} diff --git a/src/providers/siftable-work/index.ts b/src/providers/siftable-work/index.ts new file mode 100644 index 0000000..0c38362 --- /dev/null +++ b/src/providers/siftable-work/index.ts @@ -0,0 +1,277 @@ +/** Siftable hosted MCP calls. Automatic durable recovery remains contract-gated. */ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { ToolRegistry } from "../../framework/registry.js"; +import type { ExecutionPolicy, ToolExecutionContext } from "../../framework/execution-types.js"; +import type { InputSchema, ToolDefinition, ToolResult } from "../../framework/types.js"; +import type { ContextProvider } from "../../framework/context.js"; +import { createMcpProvider, type McpProviderOptions, type ConnectedMcpProvider } from "../mcp/index.js"; + +export const SIFTABLE_WORK_TOOL_ALLOWLIST = Object.freeze([ + "context_current", "work_item_get", "work_item_verification_plan_get", + "work_item_verification_history", +]); +// Discovery is an availability fact, never a model-callable custody surface. +const WORK_DISCOVERY_ALLOWLIST = Object.freeze([ + ...SIFTABLE_WORK_TOOL_ALLOWLIST, + "work_item_verification_evidence_submit", "work_session_open", "work_session_transition", "work_session_close", +]); + +export const SIFTABLE_HOSTED_RECOVERY_STATUS = Object.freeze({ + enabled: false, + inspectedVersion: "1.5.1", + reasons: Object.freeze([ + "API deduplication key scope, receipt retention, payload-conflict behavior, and authoritative receipt lookup are not established by the published package.", + "Evidence submission generates a fresh transport Idempotency-Key per invocation; attemptId uniqueness and safe resubmission are not established.", + "Closed session replay compares action and key without verifying the changed payload.", + "The published export map provides no supported work-contract parser; parseAgentWorkContract is vendored privately.", + ]), +}); + +export interface SiftableActionReceipt { + kind: "sift.action_receipt"; + schemaVersion: 1; + receiptId: string; + completedAt: string; + surface: "mcp"; + binding: { status: "bound"; action: { id: string; version: number } } | { status: "unbound"; action: null }; + operation: { identifier: string; schemaDigest: string | null }; + outcome: "succeeded" | "rejected" | "failed"; + result: { contentType: "application/json"; schemaId: string; value: Record } + | { contentType: "text/plain"; value: string } | null; + error: { code: string; message: string; recovery: string | null; details: Record | null } | null; + idempotency?: { key: string; payloadDigest: string; disposition: "created" | "replayed"; duplicateBehavior: "replay_same_payload_reject_changed_payload" }; +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Validate the published receipt shape; failed is never a not-started proof. */ +export function parseSiftableActionReceipt(value: unknown, expectedTool?: string): SiftableActionReceipt | undefined { + if (!object(value) || value.kind !== "sift.action_receipt" || value.schemaVersion !== 1 || + typeof value.receiptId !== "string" || !value.receiptId || + typeof value.completedAt !== "string" || !Number.isFinite(Date.parse(value.completedAt)) || + value.surface !== "mcp" || !object(value.operation) || + typeof value.operation.identifier !== "string" || + !(value.operation.schemaDigest === null || typeof value.operation.schemaDigest === "string") || + (expectedTool !== undefined && value.operation.identifier !== expectedTool) || + !["succeeded", "rejected", "failed"].includes(String(value.outcome)) || + !object(value.binding) || !["bound", "unbound"].includes(String(value.binding.status))) return undefined; + if (value.binding.status === "unbound" && value.binding.action !== null) return undefined; + if (value.binding.status === "bound" && (!object(value.binding.action) || typeof value.binding.action.id !== "string" || !Number.isInteger(value.binding.action.version))) return undefined; + if (value.outcome === "succeeded") { + if (value.error !== null || !object(value.result)) return undefined; + if (value.result.contentType === "application/json") { + if (typeof value.result.schemaId !== "string" || !object(value.result.value)) return undefined; + } else if (value.result.contentType !== "text/plain" || typeof value.result.value !== "string") return undefined; + } else if (value.result !== null || !object(value.error) || + typeof value.error.code !== "string" || typeof value.error.message !== "string" || + !(value.error.recovery === null || typeof value.error.recovery === "string") || + !(value.error.details === null || object(value.error.details))) return undefined; + if (value.idempotency !== undefined && (!object(value.idempotency) || + typeof value.idempotency.key !== "string" || typeof value.idempotency.payloadDigest !== "string" || + !["created", "replayed"].includes(String(value.idempotency.disposition)) || + value.idempotency.duplicateBehavior !== "replay_same_payload_reject_changed_payload")) return undefined; + return value as unknown as SiftableActionReceipt; +} + +export interface SiftableAuthenticatedContext { + principalId: string; + principalKind: "user" | "workspace_service" | "oauth_client"; + workspaceId: string | null; + projectId: string | null; + effectiveScopes: readonly string[]; +} + +export interface SiftableMcpProviderOptions extends Omit { + id?: string; + /** A trusted host selection to check against the authenticated server context. */ + expectedWorkspaceId?: string | null; + projectId?: string; + repositoryId?: string; + /** Explicit trusted-host selection; default profiles cannot submit evidence. */ + evidenceExecution?: { + policy: ExecutionPolicy; + /** Resolve authority from host state, never from model arguments. */ + resolveContext: () => ToolExecutionContext | Promise; + }; +} + +export interface ConnectedSiftableMcpProvider extends ConnectedMcpProvider { + readonly authenticatedContext: SiftableAuthenticatedContext; + /** Trusted application code can bind this before the registry's policy runs. */ + readonly executionContext: Pick; + /** Advertised by the authenticated remote catalog; never exposed by createTools(). */ + readonly hostedSessionsAvailable: boolean; + readonly evidenceSubmissionAvailable: boolean; +} +export interface SiftableMcpProvider extends Omit { + connect(config?: Record): Promise; +} + +function forbiddenTool(name: string): boolean { + return name.startsWith("vault_") || name === "work_item_claim" || name === "work_item_transition" || + /^work_item_(?:start|heartbeat|block|review|complete|fail|cancel|release)$/.test(name); +} + +function contextFrom(result: ToolResult, options: SiftableMcpProviderOptions): SiftableAuthenticatedContext { + const receipt = parseSiftableActionReceipt(result.structuredContent, "context_current"); + const value = receipt?.result?.contentType === "application/json" + ? receipt.result.value : result.structuredContent; + if (!result.success || !object(value) || value.contractVersion !== 1 || !object(value.principal) || + value.principal.authority !== "validated_token" || typeof value.principal.id !== "string" || !value.principal.id || + !["user", "workspace_service", "oauth_client"].includes(String(value.principal.kind)) || + !object(value.queryContext) || !object(value.authorization) || !Array.isArray(value.authorization.effectiveScopes) || + !value.authorization.effectiveScopes.every((scope) => typeof scope === "string")) { + throw new Error("Siftable hosted work requires context_current from a validated authenticated principal; connect with host-scoped credentials"); + } + const workspaceId = value.queryContext.selectedWorkspaceContextId; + if (!(workspaceId === null || typeof workspaceId === "string")) throw new Error("Siftable context did not establish its workspace scope"); + if (options.expectedWorkspaceId !== undefined && workspaceId !== options.expectedWorkspaceId) throw new Error("Siftable authenticated workspace does not match the host selection; reconnect with matching scope"); + let projectId: string | null = null; + if (value.project !== null) { + if (!object(value.project) || typeof value.project.id !== "string") throw new Error("Siftable context returned an invalid project reference"); + projectId = value.project.id; + } + if (options.projectId !== undefined && projectId !== options.projectId) throw new Error("Siftable project does not match the host's explicit selection"); + return Object.freeze({ + principalId: value.principal.id, + principalKind: value.principal.kind as SiftableAuthenticatedContext["principalKind"], + workspaceId, projectId, + effectiveScopes: Object.freeze([...value.authorization.effectiveScopes] as string[]), + }); +} + +/** Hosted calls use generic MCP; they do not load the optional embedded SDK. */ +export function createSiftableMcpProvider(options: SiftableMcpProviderOptions): SiftableMcpProvider { + if (options.transport?.kind === "stdio") throw new Error("Siftable hosted work requires authenticated StreamableHTTP; use the embedded provider for local ordinary domains"); + if (options.evidenceExecution && (typeof options.evidenceExecution.policy?.authorize !== "function" || + typeof options.evidenceExecution.resolveContext !== "function")) { + throw new Error("Hosted evidence requires a trusted context resolver and execution policy"); + } + const allowed = options.toolAllowlist ?? WORK_DISCOVERY_ALLOWLIST; + for (const name of allowed) if (forbiddenTool(name)) throw new Error(`Siftable work profile refuses raw lifecycle or vault tools: ${name}`); + const createGeneric = (captureCatalog?: (catalog: readonly { name: string }[]) => void) => createMcpProvider({ + ...options, + id: options.id ?? "siftable-work", + name: options.name ?? "Siftable hosted work", + capabilities: options.capabilities ?? ["agent_work"], + // Optional hosted capabilities are filtered by the actual authenticated catalog. + toolAllowlist: options.toolAllowlist, + approveCatalog: async (catalog) => { + captureCatalog?.(catalog); + const approved = options.approveCatalog ? await options.approveCatalog(catalog) : catalog.map((tool) => tool.name); + return approved.filter((name) => allowed.includes(name) && !forbiddenTool(name)); + }, + isAuthoritativeRejection: (result: CallToolResult, name) => parseSiftableActionReceipt(result.structuredContent, name)?.outcome === "rejected", + }); + return { + metadata: createGeneric().metadata, + async connect() { + const advertisedNames = new Set(); + const connected = await createGeneric((catalog) => { + for (const tool of catalog) advertisedNames.add(tool.name); + }).connect(); + try { + const registry = new ToolRegistry(); + const contextTool = connected.createTools().find((tool) => tool.name === "context_current"); + if (!contextTool) throw new Error("Siftable hosted connection must advertise context_current before any work call"); + registry.register(contextTool); + const result = await registry.execute("context_current", { + ...(options.projectId ? { projectId: options.projectId } : {}), + ...(options.repositoryId ? { repositoryId: options.repositoryId } : {}), + }); + const authenticatedContext = contextFrom(result, options); + const evidenceIdentity = authenticatedContext.principalKind === "user"; + const evidenceRegistry = options.evidenceExecution + ? new ToolRegistry({ mode: "governed", policy: options.evidenceExecution.policy }) + : undefined; + const executionContext = Object.freeze({ + actor: Object.freeze({ kind: authenticatedContext.principalKind === "workspace_service" ? "service" as const : "host" as const, id: authenticatedContext.principalId }), + scope: Object.freeze({ + ...(authenticatedContext.workspaceId === null ? {} : { workspaceId: authenticatedContext.workspaceId }), + ...(authenticatedContext.projectId === null ? {} : { projectId: authenticatedContext.projectId }), + }), + }); + return { + ...connected, + get catalog() { return connected.catalog; }, + get catalogStale() { return connected.catalogStale; }, + authenticatedContext, executionContext, + get hostedSessionsAvailable() { return ["work_session_open", "work_session_transition", "work_session_close"].every((name) => advertisedNames.has(name)); }, + get evidenceSubmissionAvailable() { return !!evidenceRegistry && evidenceIdentity && connected.catalog.tools.some((tool) => tool.name === "work_item_verification_evidence_submit"); }, + async refreshCatalog() { + throw new Error("Reconnect Siftable at a new session boundary to refresh both authenticated context and catalog"); + }, + createTools() { + return connected.createTools().filter((tool) => + !tool.name.startsWith("work_session_") && + (tool.name !== "work_item_verification_evidence_submit" || (evidenceIdentity && evidenceRegistry !== undefined)) + ).map((upstreamTool): ToolDefinition => { + const tool: ToolDefinition = { + ...upstreamTool, + handler: async (params, context) => { + if (Object.hasOwn(params, "workspaceId") && params.workspaceId !== authenticatedContext.workspaceId) { + return { success: false, executionOutcome: "failed", error: "Workspace arguments must match the host's authenticated selection; reconnect with the selected scope" }; + } + if (options.projectId !== undefined && Object.hasOwn(params, "projectId") && params.projectId !== options.projectId) { + return { success: false, executionOutcome: "failed", error: "Project arguments must match the host's explicit selection" }; + } + return upstreamTool.handler(params, context); + }, + }; + if (tool.name !== "work_item_verification_evidence_submit") return tool; + const inputSchema = structuredClone(tool.inputSchema) as InputSchema; + delete inputSchema.properties.claimToken; + delete inputSchema.properties.claimOwner; + inputSchema.additionalProperties = false; + const protectedEvidence: ToolDefinition = { + ...tool, inputSchema, + replayPolicy: "never", + contract: { + version: "1", + useWhen: ["Submit external evidence under a validated trusted-host grant"], + doNotUseWhen: ["No trusted evidence policy or authenticated user binding"], + sideEffects: ["Creates a hosted verification evidence record"], + mutation: true, commitmentClass: "C2", idempotency: "non_idempotent", + requiresAuthority: true, + }, + handler: async (params, context) => { + if (Object.hasOwn(params, "claimToken") || Object.hasOwn(params, "claimOwner")) { + return { success: false, executionOutcome: "failed", error: "Hosted evidence uses the authenticated user; raw lease custody is unavailable through this work profile" }; + } + return tool.handler(params, context); + }, + }; + if (!evidenceRegistry!.get(tool.name)) evidenceRegistry!.register(protectedEvidence); + return { + ...protectedEvidence, + handler: async (params, context) => { + const host = await options.evidenceExecution!.resolveContext(); + if (host.actor?.id !== authenticatedContext.principalId || + (host.scope?.workspaceId ?? null) !== authenticatedContext.workspaceId || + (host.scope?.projectId ?? null) !== authenticatedContext.projectId) { + return { success: false, executionOutcome: "failed", error: "Trusted evidence context must match the authenticated principal and selected scope" }; + } + return evidenceRegistry!.execute(tool.name, params, { + ...host, + ...(context?.signal ? { signal: host.signal ? AbortSignal.any([host.signal, context.signal]) : context.signal } : {}), + ...(context?.correlationId ? { correlationId: context.correlationId } : {}), + }); + }, + }; + }); + }, + }; + } catch (error) { + await connected.disconnect(); + throw error; + } + }, + }; +} + +/** Deliberately fail before claiming any work; the published recovery gate is incomplete. */ +export function createSiftableWorkBackend(): never { + throw new Error(`Siftable durable hosted backend is unavailable: ${SIFTABLE_HOSTED_RECOVERY_STATUS.reasons.join(" ")}`); +} diff --git a/src/runtime/work-profile.ts b/src/runtime/work-profile.ts new file mode 100644 index 0000000..38d33ce --- /dev/null +++ b/src/runtime/work-profile.ts @@ -0,0 +1,106 @@ +/** Trusted single-user deployment configuration for a bounded local work runtime. */ +import { isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { ToolRegistry } from "../framework/registry.js"; +import type { ToolDefinition } from "../framework/types.js"; +import { readPrivateFile, JournalInvariantError, initializePrivateRoot } from "../framework/journal-file.js"; +import { LocalGrantStore } from "../framework/work/grants.js"; +import { LocalWorkBackend, type LocalWorkItem } from "../framework/work/local-backend.js"; +import { WorkAttemptController } from "../framework/work/controller.js"; +import { createWorkTools } from "../framework/work/tools.js"; +import { createLocalFixtureBindings } from "../framework/work/local-fixture.js"; +import { scopedPath } from "../framework/work/verification.js"; +import type { ResolvedWorkGrant, VerificationBinding, WorkStepBinding } from "../framework/work/types.js"; + +export interface WorkProfileConfig { + schemaVersion: 1; + workspaceRoot: string; + dataDir: string; + grantDir: string; + backend: { kind: "local"; backendId: string; workspaceId: string; projectId?: string; workItems: LocalWorkItem[] }; + stepCatalog: { kind: "fixture"; fixturePath: string; verifierPath: string } | { kind: "module"; modulePath: string }; + limits?: ResolvedWorkGrant["limits"]; + grantTtlMs?: number; + claimOwner?: string; + leaseSeconds?: number; +} +export interface TrustedWorkBindings { + tools: ToolDefinition[]; + steps: Record; + verificationBindings: readonly VerificationBinding[]; +} +export interface WorkProfile { + config: Readonly; + configPath: string; + controller: WorkAttemptController; + registry: ToolRegistry; + grants: LocalGrantStore; + limits: ResolvedWorkGrant["limits"]; + grantTtlMs: number; +} +const defaultLimits = { maxAttempts: 1, maxDurationMs: 60 * 60 * 1000, maxActions: 20, maxOutputBytes: 64 * 1024 * 1024 }; + +/** Revocation remains available even when a job's target or module is missing. */ +export function readWorkGrantDirectory(configPath: string): string { + if (!isAbsolute(configPath)) throw new JournalInvariantError("Work config path must be absolute"); + const config = JSON.parse(readPrivateFile(configPath, 1024 * 1024).toString("utf8")) as { schemaVersion?: number; grantDir?: string }; + if (config?.schemaVersion !== 1 || typeof config.grantDir !== "string" || !isAbsolute(config.grantDir)) throw new JournalInvariantError("Invalid trusted grant directory config"); + return config.grantDir; +} + +export function readWorkProfileConfig(configPath: string): WorkProfileConfig { + if (!isAbsolute(configPath)) throw new JournalInvariantError("Work config path must be absolute"); + let config: WorkProfileConfig; + try { config = JSON.parse(readPrivateFile(configPath, 1024 * 1024).toString("utf8")) as WorkProfileConfig; } + catch (error) { if (error instanceof SyntaxError) throw new JournalInvariantError("Work config is not valid JSON"); throw error; } + if (!config || config.schemaVersion !== 1 || !config.backend || config.backend.kind !== "local" || !config.backend.backendId || !config.backend.workspaceId || !Array.isArray(config.backend.workItems) || !config.backend.workItems.length) throw new JournalInvariantError("Invalid local work profile identity/backend"); + const rejectUnknown = (value: object, allowed: readonly string[], label: string): void => { + if (Object.keys(value).some((key) => !allowed.includes(key))) throw new JournalInvariantError(`Unexpected ${label} field`); + }; + rejectUnknown(config, ["schemaVersion", "workspaceRoot", "dataDir", "grantDir", "backend", "stepCatalog", "limits", "grantTtlMs", "claimOwner", "leaseSeconds"], "work config"); + rejectUnknown(config.backend, ["kind", "backendId", "workspaceId", "projectId", "workItems"], "local backend"); + for (const key of ["workspaceRoot", "dataDir", "grantDir"] as const) { + if (typeof config[key] !== "string" || !isAbsolute(config[key])) throw new JournalInvariantError(`Work profile ${key} must be absolute`); + } + scopedPath(config.workspaceRoot, "."); + if (!config.stepCatalog || !["fixture", "module"].includes(config.stepCatalog.kind)) throw new JournalInvariantError("Work profile requires a finite trusted step catalog"); + rejectUnknown(config.stepCatalog, config.stepCatalog.kind === "fixture" ? ["kind", "fixturePath", "verifierPath"] : ["kind", "modulePath"], "step catalog"); + if (config.stepCatalog.kind === "fixture") { + if (typeof config.stepCatalog.fixturePath !== "string" || typeof config.stepCatalog.verifierPath !== "string") throw new JournalInvariantError("Fixture catalog requires bound file paths"); + scopedPath(config.workspaceRoot, config.stepCatalog.fixturePath); + scopedPath(config.workspaceRoot, config.stepCatalog.verifierPath); + } else if (!isAbsolute(config.stepCatalog.modulePath)) throw new JournalInvariantError("Trusted module path must be absolute"); + for (const item of config.backend.workItems) { + if (!item.workItemId || !item.contract || item.contract.version !== 1 || !Array.isArray(item.contract.roots) || !item.contract.roots.length || !item.plan || item.plan.state !== "active") throw new JournalInvariantError("Work profile has an invalid work contract or inactive verification plan"); + for (const root of item.contract.roots) { if (!isAbsolute(root)) throw new JournalInvariantError("Contract roots must be absolute"); scopedPath(config.workspaceRoot, root); } + } + if (config.limits) rejectUnknown(config.limits, Object.keys(defaultLimits), "work limits"); + for (const [key, value] of Object.entries(config.limits ?? defaultLimits)) if (!Number.isSafeInteger(value) || value <= 0) throw new JournalInvariantError(`Work limit ${key} must be a positive safe integer`); + if (config.limits && Object.keys(defaultLimits).some((key) => !(key in config.limits!))) throw new JournalInvariantError("All work limits must be specified together"); + if (config.grantTtlMs !== undefined && (!Number.isSafeInteger(config.grantTtlMs) || config.grantTtlMs <= 0)) throw new JournalInvariantError("Grant TTL must be a positive safe integer"); + if (config.leaseSeconds !== undefined && (!Number.isFinite(config.leaseSeconds) || config.leaseSeconds <= 0)) throw new JournalInvariantError("Lease seconds must be positive"); + return config; +} + +export async function createWorkProfile(configPath: string): Promise { + const config = readWorkProfileConfig(configPath); + initializePrivateRoot(config.dataDir); + const grants = new LocalGrantStore({ rootDir: config.grantDir }); + let bindings: TrustedWorkBindings; + if (config.stepCatalog.kind === "fixture") { + bindings = createLocalFixtureBindings({ workspaceRoot: config.workspaceRoot, fixturePath: config.stepCatalog.fixturePath, verifierPath: config.stepCatalog.verifierPath }); + } else { + // The module is explicitly selected by the trusted config, never tool arguments. + const module = await import(pathToFileURL(resolve(config.stepCatalog.modulePath)).href); + if (typeof module.createWorkBindings !== "function") throw new JournalInvariantError("Trusted module must export createWorkBindings({ workspaceRoot })"); + bindings = await module.createWorkBindings({ workspaceRoot: config.workspaceRoot }); + } + if (!bindings || !Array.isArray(bindings.tools) || !bindings.steps || !Object.keys(bindings.steps).length || !Array.isArray(bindings.verificationBindings) || !bindings.verificationBindings.length) throw new JournalInvariantError("Trusted catalog must export tools, finite steps, and verification bindings"); + const executionRegistry = new ToolRegistry(); + executionRegistry.registerAll(bindings.tools); + const backend = new LocalWorkBackend({ rootDir: join(config.dataDir, "backend"), backendId: config.backend.backendId, workspaceId: config.backend.workspaceId, ...(config.backend.projectId ? { projectId: config.backend.projectId } : {}), workItems: config.backend.workItems }); + const controller = new WorkAttemptController({ backend, registry: executionRegistry, grantResolver: grants, journalRoot: join(config.dataDir, "attempts"), artifactRoot: join(config.dataDir, "artifacts"), workspaceRoot: config.workspaceRoot, claimOwner: config.claimOwner ?? `local:${config.backend.workspaceId}`, steps: bindings.steps, verificationBindings: bindings.verificationBindings, ...(config.leaseSeconds === undefined ? {} : { leaseSeconds: config.leaseSeconds }) }); + const registry = new ToolRegistry(); + registry.registerAll(createWorkTools(controller)); + return { config, configPath, controller, registry, grants, limits: config.limits ?? { ...defaultLimits }, grantTtlMs: config.grantTtlMs ?? defaultLimits.maxDurationMs }; +} diff --git a/src/workshop.ts b/src/workshop.ts new file mode 100644 index 0000000..cf70cea --- /dev/null +++ b/src/workshop.ts @@ -0,0 +1,17 @@ +// Load .env file if it exists +import "./framework/env.js"; + +/** + * OpenFunction — Entry Point + * + * Registers all tools and starts the MCP server. + * To add your own tools, edit src/my-tools/index.ts. + */ + +import { startServer } from "./framework/index.js"; +import { registry } from "./register-tools.js"; + +await startServer(registry, { + name: "openfunction", + version: "1.0.0", +}); diff --git a/test-client/fixtures/hermes-host-proof.py b/test-client/fixtures/hermes-host-proof.py new file mode 100644 index 0000000..e0a5991 --- /dev/null +++ b/test-client/fixtures/hermes-host-proof.py @@ -0,0 +1,386 @@ +"""Repository-owned harness calling the real pinned Hermes discovery and dispatch path. + +Only user approval responses and the localhost model are controlled fixtures. MCP +transport, registry handlers, discovery annotations, and host trust enforcement are real. +""" +import importlib.metadata +import json +import os +from pathlib import Path +import sys +import threading +import subprocess +import signal +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + +inputs = json.loads(Path(sys.argv[1]).read_text()) +assert Path(sys.executable).resolve() == Path(inputs["python"]).resolve(), "Wrong managed interpreter" +from hermes_cli.agent_plugins import load_agent_plugin +from tools.mcp_tool_discovery import register_mcp_servers +from tools.mcp_tool_lifecycle import shutdown_mcp_servers +from tools.mcp_tool_handlers import _tool_is_read_only +from tools.registry import registry + +home = Path(os.environ["HERMES_HOME"]) +config = json.loads((home / "config.yaml").read_text()) +names = register_mcp_servers(config["mcp_servers"]) +read_name = next(name for name in names if name.endswith("smoke_read")) +write_name = next(name for name in names if name.endswith("smoke_write")) +assert _tool_is_read_only("openfunctions", "smoke_read") +assert not _tool_is_read_only("openfunctions", "smoke_write") + +model_requests = [] +model_action = {"name": read_name, "arguments": {}} +class LocalModel(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if self.path != "/v1/chat/completions": + self.send_error(404, "Unsupported local fixture endpoint") + return + model_requests.append(payload) + calls = [message for message in payload["messages"] if message["role"] == "tool"] + message = {"role": "assistant", "content": "HERMES_LOCAL_PROOF_DONE"} + reason = "stop" + if not calls: + available = [tool["function"]["name"] for tool in payload.get("tools", [])] + # The pin exposes deferred tools through its normal tool_call bridge. + requested, supplied = model_action["name"], model_action["arguments"] + target, arguments = (requested, supplied) if requested in available else ("tool_call", {"calls": [{"name": requested, "arguments": supplied}]}) + assert target in available, available + message = {"role": "assistant", "content": None, "tool_calls": [{"id": "local_read_1", "type": "function", "function": {"name": target, "arguments": json.dumps(arguments)}}]} + reason = "tool_calls" + response = {"id": "local-proof", "object": "chat.completion", "created": 1, "model": "local-proof", "choices": [{"index": 0, "message": message, "finish_reason": reason}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}} + if payload.get("stream"): + delta = dict(message) + for index, call in enumerate(delta.get("tool_calls", [])): + call["index"] = index + response["object"] = "chat.completion.chunk" + response["choices"] = [{"index": 0, "delta": delta, "finish_reason": reason}] + body = ("data: " + json.dumps(response) + "\n\ndata: [DONE]\n\n").encode() + kind = "text/event-stream" + else: + body = json.dumps(response).encode() + kind = "application/json" + self.send_response(200) + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +local_model = ThreadingHTTPServer(("127.0.0.1", 0), LocalModel) +threading.Thread(target=local_model.serve_forever, daemon=True).start() + +def decode_host_result(raw): + payload = json.loads(raw) if isinstance(raw, str) else raw + if isinstance(payload, dict) and isinstance(payload.get("result"), str): + return json.loads(payload["result"]) + return payload + +def model_tool(name, arguments, allow_error=False): + from run_agent import AIAgent + model_action.update(name=name, arguments=arguments) + outputs = [] + agent = AIAgent(base_url=f"http://127.0.0.1:{local_model.server_port}/v1", api_key="local-fixture-only", provider="custom", api_mode="chat_completions", model="local-proof", enabled_toolsets=["mcp-openfunctions"], max_iterations=3, quiet_mode=True, skip_context_files=True, skip_memory=True, skip_background_review=True, save_trajectories=False, cwd=str(home), tool_complete_callback=lambda _id, _name, _args, result: outputs.append(result)) + with patch("tools.approval_prompt.request_elicitation_consent", return_value="accept"): + turn = agent.run_conversation(f"Call the configured tool {name} with these exact fixture arguments: {json.dumps(arguments)}.", system_message="Use the selected configured tool once. Report its returned result.") + assert turn["final_response"] == "HERMES_LOCAL_PROOF_DONE", turn + assert outputs, turn + result = decode_host_result(outputs[-1]) + if not allow_error: + assert "error" not in result, result + return result + +def cli(*args, input_text=None): + call = subprocess.run([inputs["binary"], *args], input=input_text, text=True, capture_output=True, check=True) + log = Path(inputs["scratch"]) / "work-cli.jsonl" + with log.open("a") as stream: + stream.write(json.dumps({"argv": list(args), "stdout": call.stdout, "stderr": call.stderr}) + "\n") + return call.stdout + +def authorize_job(config_path, work_item): + preview = Path(inputs["scratch"]) / f"preview-{work_item}.json" + cli("work", "prepare", "--config", str(config_path), "--work-item", work_item, "--out", str(preview)) + cli("work", "authorize", "--config", str(config_path), "--preview", str(preview), input_text="AUTHORIZE\nCOMPLETE\n") + grants = json.loads(cli("work", "grants", "list", "--config", str(config_path))) + matching = [grant for grant in grants if grant["workItemId"] == work_item] + assert len(matching) == 2 and any(grant["completion"] for grant in matching), matching + return matching + +def kill_work_process(workspace): + import psutil + identity = json.loads((workspace / "work-server-process.json").read_text()) + process = psutil.Process(identity["pid"]) + assert os.getpid() in [parent.pid for parent in process.parents()], "Fixture PID is not owned by this isolated Hermes process" + assert abs(process.create_time() * 1000 - identity["startedAt"]) < 3000, "Fixture PID was recycled" + process.kill() + return identity + +def work_proof(servers): + shutdown_mcp_servers() + data_root = Path(servers["openfunctions"]["cwd"]) + workspace = Path(inputs["scratch"]) / "work-subject" + workspace.mkdir(mode=0o700) + fixture = workspace / "fixture.json" + fixture.write_text(json.dumps({"status": "needs_correction", "value": 0})) + verifier = workspace / "verify.mjs" + verifier.write_text("import {readFileSync} from 'node:fs'; const x=JSON.parse(readFileSync(process.argv[2],'utf8')); console.log(JSON.stringify(x)); process.exit(x.status==='corrected'&&x.value===1?0:1);") + catalog = Path(inputs["fixture"]).parent / "trusted-work.mjs" + catalog.write_text("import {createLocalFixtureBindings} from 'openfunction/framework/work'; import {existsSync,writeFileSync} from 'node:fs'; import {basename,join} from 'node:path';\nexport function createWorkBindings({workspaceRoot}) {const bindings=createLocalFixtureBindings({workspaceRoot,fixturePath:'fixture.json',verifierPath:'verify.mjs',afterEffect:async()=>{writeFileSync(join(workspaceRoot,'work-server-process.json'),JSON.stringify({pid:process.pid,startedAt:Date.now()-process.uptime()*1000}),{mode:0o600});if(existsSync(join(workspaceRoot,'park-after-effect'))){writeFileSync(join(workspaceRoot,'effect-ready'),'ready');await new Promise(()=>{});}else{await new Promise(resolve=>setTimeout(resolve,3500));}}});if(basename(workspaceRoot)==='uncertain-subject'){delete bindings.steps.correct.prepareReconciliation;delete bindings.steps.correct.reconcileEffect;delete bindings.steps.correct.reconciliationSpecDigest;}return bindings;}\n") + config_path = data_root / "config.json" + generator = Path(inputs["fixture"]).parent / "prepare-work.mjs" + generator.write_text("import {writeFileSync} from 'node:fs'; import {createWorkBindings} from './trusted-work.mjs';\n" + + "const [workspaceRoot,dataDir,grantDir,modulePath,configPath]=process.argv.slice(2);\n" + + "const b=createWorkBindings({workspaceRoot}); const workItems=['correction','stale','revocation','uncertain','oracle'].map(workItemId=>({workItemId,contract:b.contract,plan:{id:'fixture-plan-'+workItemId,version:1,state:'active',steps:[b.verificationStep]}}));\n" + + "writeFileSync(configPath,JSON.stringify({schemaVersion:1,workspaceRoot,dataDir,grantDir,backend:{kind:'local',backendId:'hermes-local-proof',workspaceId:'profile-a',workItems},stepCatalog:{kind:'module',modulePath},leaseSeconds:2,limits:{maxAttempts:1,maxDurationMs:60000,maxActions:20,maxOutputBytes:1048576}}),{mode:0o600});\n") + subprocess.run(["node", str(generator), str(workspace), str(data_root / "runtime"), str(data_root / "grants"), str(catalog), str(config_path)], cwd=catalog.parent, check=True) + config_path.chmod(0o600) + # Drop the earlier ordinary smoke override; use the actual portable MCP declaration. + from hermes_cli.config import load_config, save_config + native = load_config() + native["mcp_servers"] = {} + save_config(native) + names = register_mcp_servers(servers) + prefix = "mcp__openfunctions__" + assert set(names) == {prefix + name for name in ["of_work_prepare", "of_work_start", "of_work_step", "of_work_verify", "of_work_status", "of_work_reconcile", "of_work_finish"]}, names + preview = model_tool(prefix + "of_work_prepare", {"workItemId": "correction"}) + assert preview["binding"]["workItemId"] == "correction" + grants = authorize_job(config_path, "correction") + started = model_tool(prefix + "of_work_start", {"workItemId": "correction"}) + assert started["state"] == "running", started + inspected = model_tool(prefix + "of_work_step", {"stepKey": "inspect", "params": {}}) + digest = inspected["result"]["data"]["expectedDigest"] + before = time.monotonic() + # Interrupt success delivery after the real RPC and controller journal commit, + # before Hermes receives the successful model-facing tool result. + from tools import mcp_tool_handlers as handlers + original_render = handlers._render_call_tool_result + lost_results = [] + killed_processes = [] + def lose_success(result, server_name, image_paths=None): + raw = original_render(result, server_name, image_paths) + decoded = decode_host_result(raw) + if server_name == "openfunctions" and decoded.get("receipt", {}).get("toolName") == "fixture_correct": + lost_results.append(decoded) + killed_processes.append(kill_work_process(workspace)) + raise RuntimeError("Fixture interrupted successful result before model delivery") + return raw + with patch.object(handlers, "_render_call_tool_result", side_effect=lose_success): + lost_delivery = model_tool(prefix + "of_work_step", {"stepKey": "correct", "params": {"expectedDigest": digest}}, allow_error=True) + assert "error" in lost_delivery, lost_delivery + assert len(lost_results) == 1, lost_results + corrected = lost_results[0] + assert corrected["outcome"] == "succeeded", corrected + assert time.monotonic() - before >= 3.5 + assert json.loads(fixture.read_text())["value"] == 1 + backend_lines = list((data_root / "runtime" / "backend").glob("*.jsonl")) + assert any('"kind":"heartbeat"' in path.read_text() for path in backend_lines), "Long step did not renew lease" + # A real process interruption after a recorded result preserves the subject and receipts. + prior_mtime = fixture.stat().st_mtime_ns + killed = killed_processes[0] + shutdown_mcp_servers() + register_mcp_servers(servers) + recovered = model_tool(prefix + "of_work_reconcile", {}) + assert recovered["state"] == "running", recovered + assert recovered["lastStepExecution"]["result"]["data"]["corrected"] is True, recovered + assert recovered["lastStepExecution"]["executionId"] == corrected["receipt"]["executionId"], recovered + assert fixture.stat().st_mtime_ns == prior_mtime, "Recovery repeated a known write" + verified = model_tool(prefix + "of_work_verify", {"stepId": "fixture-check"}) + assert verified["outcome"] == "passed", verified + finished = model_tool(prefix + "of_work_finish", {}) + assert finished["state"] == "completed", finished + completed_config = data_root / "completed-config.json" + completed_config.write_bytes(config_path.read_bytes()) + completed_config.chmod(0o600) + + def activate_case(case, initial=None): + shutdown_mcp_servers() + target = Path(inputs["scratch"]) / (case + "-subject") + target.mkdir(mode=0o700) + subject_file = target / "fixture.json" + subject_file.write_text(json.dumps(initial or {"status": "corrected", "value": 1})) + (target / "verify.mjs").write_bytes(verifier.read_bytes()) + subprocess.run(["node", str(generator), str(target), str(data_root / (case + "-runtime")), str(data_root / (case + "-grants")), str(catalog), str(config_path)], cwd=catalog.parent, check=True) + config_path.chmod(0o600) + case_grants = authorize_job(config_path, case) + register_mcp_servers(servers) + model_tool(prefix + "of_work_start", {"workItemId": case}) + return target, subject_file, case_grants + + _, stale_fixture, _ = activate_case("stale") + stale_verified = model_tool(prefix + "of_work_verify", {"stepId": "fixture-check"}) + assert stale_verified["outcome"] == "passed", stale_verified + subject = json.loads(stale_fixture.read_text()) + subject["interference"] = "changed after passing verification" + stale_fixture.write_text(json.dumps(subject)) + stale_finish = model_tool(prefix + "of_work_finish", {}) + assert stale_finish["state"] == "needs_review", stale_finish + # Readback still passes, so this refusal specifically tests the subject digest. + assert subject["status"] == "corrected" and subject["value"] == 1 + stale_readback = model_tool(prefix + "of_work_status", {}) + assert stale_readback["backendState"] == "needs_review", stale_readback + # Revoke an actual persisted execution grant while the portable server stays enabled. + _, _, revocation_grants = activate_case("revocation") + execution_grant = next(grant for grant in revocation_grants if grant["purpose"] == "execution") + cli("work", "grants", "revoke", "--config", str(config_path), "--grant", execution_grant["id"]) + denied = model_tool(prefix + "of_work_step", {"stepKey": "inspect", "params": {}}, allow_error=True) + assert "grant" in denied.get("error", "").lower(), denied + def interrupt_after_effect(case, case_workspace, case_fixture, expected_digest): + (case_workspace / "park-after-effect").write_text("park") + interrupted = [] + with patch("tools.approval_prompt.request_elicitation_consent", return_value="accept"): + thread = threading.Thread(target=lambda: interrupted.append(registry.dispatch(prefix + "of_work_step", {"stepKey": "correct", "params": {"expectedDigest": expected_digest}})), daemon=True) + thread.start() + deadline = time.monotonic() + 20 + while not (case_workspace / "effect-ready").exists(): + assert time.monotonic() < deadline, "Unknown-effect fixture never entered its post-effect barrier" + time.sleep(0.025) + mtime = case_fixture.stat().st_mtime_ns + killed = kill_work_process(case_workspace) + thread.join(timeout=40) + assert not thread.is_alive(), "Host did not settle the dead write RPC" + assert interrupted and "error" in json.loads(interrupted[0]), interrupted + shutdown_mcp_servers() + execution_root = data_root / (case + "-runtime") / "attempts" / "executions" + preserved = {path: path.read_bytes() for path in execution_root.glob("*.jsonl")} + assert preserved, "Interrupted execution journal missing" + events = [json.loads(line) for content in preserved.values() for line in content.splitlines()] + originals = [event for event in events if event["type"] == "started" and event["data"].get("receipt", {}).get("toolName") == "fixture_correct"] + assert len(originals) == 1, originals + original = originals[0] + assert original["data"]["receipt"]["executionOutcome"] == "unknown", original + assert not any(event["executionId"] == original["executionId"] and event["type"] == "settled" for event in events), events + register_mcp_servers(servers) + return {"mtime": mtime, "killed": killed, "original": original, "preserved": preserved, "interrupted": interrupted[0]} + + def assert_original_retained(crash): + for path, content in crash["preserved"].items(): + assert path.read_bytes().startswith(content), "Reconciliation rewrote original execution evidence" + current = [json.loads(line) for path in crash["preserved"] for line in path.read_bytes().splitlines()] + own = [event for event in current if event["executionId"] == crash["original"]["executionId"]] + assert own[-1] == crash["original"] and own[-1]["data"]["receipt"]["executionOutcome"] == "unknown", own + assert not any(event["type"] == "settled" for event in own), own + return current + + unknown_workspace, unknown_fixture, _ = activate_case("uncertain", {"status": "needs_correction", "value": 0}) + inspected_unknown = model_tool(prefix + "of_work_step", {"stepKey": "inspect", "params": {}}) + unknown_crash = interrupt_after_effect("uncertain", unknown_workspace, unknown_fixture, inspected_unknown["result"]["data"]["expectedDigest"]) + unknown_recovery = model_tool(prefix + "of_work_reconcile", {}) + assert unknown_recovery["state"] == "reconciliation_required", unknown_recovery + assert unknown_crash["original"]["executionId"] in unknown_recovery["unknownExecutions"], unknown_recovery + assert unknown_crash["original"]["executionId"] not in unknown_recovery["effectResolutions"], unknown_recovery + blocked_finish = model_tool(prefix + "of_work_finish", {}, allow_error=True) + assert "error" in blocked_finish, blocked_finish + assert unknown_fixture.stat().st_mtime_ns == unknown_crash["mtime"], "Unsupported unknown effect was automatically repeated" + assert_original_retained(unknown_crash) + + oracle_workspace, oracle_fixture, _ = activate_case("oracle", {"status": "needs_correction", "value": 0}) + inspected_oracle = model_tool(prefix + "of_work_step", {"stepKey": "inspect", "params": {}}) + oracle_crash = interrupt_after_effect("oracle", oracle_workspace, oracle_fixture, inspected_oracle["result"]["data"]["expectedDigest"]) + before_oracle = model_tool(prefix + "of_work_status", {}) + assert before_oracle["state"] == "reconciliation_required", before_oracle + assert not before_oracle["effectResolutions"], "Status implicitly ran the effect oracle" + oracle_recovery = model_tool(prefix + "of_work_reconcile", {}) + assert oracle_recovery["state"] == "running" and not oracle_recovery["unknownExecutions"] and not oracle_recovery["inflightStepIds"], oracle_recovery + resolution = oracle_recovery["effectResolutions"][oracle_crash["original"]["executionId"]] + assert resolution["effect"] == "verified" and resolution["sourceRefs"], resolution + assert resolution["readbackExecutionId"] != oracle_crash["original"]["executionId"], resolution + current_events = assert_original_retained(oracle_crash) + readback = next(event for event in current_events if event["executionId"] == resolution["readbackExecutionId"] and event["type"] == "settled") + assert readback["data"]["receipt"]["executionOutcome"] == "succeeded" and readback["data"]["result"]["data"]["effect"] == "verified", readback + work_events = [json.loads(line) for path in (data_root / "oracle-runtime" / "attempts").glob("*.jsonl") for line in path.read_bytes().splitlines()] + assert any(event["data"].get("originalExecutionId") == oracle_crash["original"]["executionId"] and event["data"].get("readbackExecutionId") == resolution["readbackExecutionId"] and event["data"].get("disposition") == "effect_independently_verified" for event in work_events), work_events + assert oracle_fixture.stat().st_mtime_ns == oracle_crash["mtime"], "Supported oracle repeated the original effect" + oracle_verified = model_tool(prefix + "of_work_verify", {"stepId": "fixture-check"}) + assert oracle_verified["outcome"] == "passed", oracle_verified + oracle_finished = model_tool(prefix + "of_work_finish", {}) + assert oracle_finished["state"] == "completed", oracle_finished + assert_original_retained(oracle_crash) + assert oracle_fixture.stat().st_mtime_ns == oracle_crash["mtime"], "Completion repeated the original effect" + return {"state": "completed", "preview": preview, "grants": grants, "start": started, "correct": corrected, "reconcile": recovered, "verification": verified, "staleSubjectRefusal": stale_finish, "staleBackendReadback": stale_readback, "finish": finished, "revocation": denied, "workConfig": str(completed_config), "workspace": str(workspace), "killedProcess": killed, "longStepRenewal": "passed", "knownResultRecovery": "passed with exact saved result and execution ID replay", "lostDelivery": "passed at host/model delivery boundary after real RPC response", "lostDeliveryResult": lost_delivery, "unknownEffect": "passed without a trusted oracle", "unknownRecovery": unknown_recovery, "unknownFinish": blocked_finish, "unknownKilledProcess": unknown_crash["killed"], "oracleEffectRecovery": {"state": "passed", "originalUnknownReceipt": oracle_crash["original"]["data"]["receipt"], "beforeExplicitReconcile": before_oracle, "reconcile": oracle_recovery, "verification": oracle_verified, "finish": oracle_finished, "originalJournalPreserved": True, "effectRepeated": False}} + +try: + from run_agent import AIAgent + agent = AIAgent(base_url=f"http://127.0.0.1:{local_model.server_port}/v1", api_key="local-fixture-only", provider="custom", api_mode="chat_completions", model="local-proof", enabled_toolsets=["mcp-openfunctions"], max_iterations=3, quiet_mode=True, skip_context_files=True, skip_memory=True, skip_background_review=True, save_trajectories=False, cwd=str(home)) + with patch("tools.approval_prompt.request_elicitation_consent", side_effect=AssertionError("Read requested approval")): + turn = agent.run_conversation("Read the isolated fixture count with the smoke_read tool.", system_message="Use the provided tool to read the fixture. Report its result.") + assert turn["final_response"] == "HERMES_LOCAL_PROOF_DONE", turn + tool_messages = [message for message in turn["messages"] if message["role"] == "tool"] + assert tool_messages and "count" in str(tool_messages[0]["content"]), turn + assert "error" not in str(tool_messages[0]["content"]), turn + assert len(model_requests) >= 2 + with patch("tools.approval_prompt.request_elicitation_consent", side_effect=AssertionError("Read requested approval")): + read_result = registry.dispatch(read_name, {}) + with patch("tools.approval_prompt.request_elicitation_consent", return_value="decline") as consent: + refused = registry.dispatch(write_name, {}) + assert consent.call_count == 1 + assert "error" in json.loads(refused) + assert not Path(inputs["effect"]).exists(), "Refused write reached server dispatch" + with patch("tools.approval_prompt.request_elicitation_consent", return_value="accept") as consent: + approved = registry.dispatch(write_name, {}) + assert consent.call_count == 1 + assert "error" not in json.loads(approved) + assert len(Path(inputs["effect"]).read_text().splitlines()) == 1 + package = load_agent_plugin(Path(inputs["plugin"]), home / "plugin-data") + assert not package.diagnostics, package.diagnostics + assert package.mcp_servers["openfunctions"]["trust"] == "untrusted" + assert package.skills[0].name == "durable-work" + portable = "not run" + work = None + if inputs["mode"] != "smoke": + # A pinned catalog snapshot keeps this local-only host lane offline while + # preserving Hermes's ordinary catalog/recall checks during installation. + from dataclasses import asdict + from hermes_cli.plugin_catalog import load_catalog, load_removed_list + cache = home / "cache" / "plugin-catalog.json" + cache.parent.mkdir(exist_ok=True) + cache.write_text(json.dumps({"entries": [asdict(entry) for entry in load_catalog()], "removed": [asdict(entry) for entry in load_removed_list()]})) + from hermes_cli.plugins_cmd import cmd_install, cmd_enable + from hermes_cli.plugins import PluginManager + cmd_install(inputs["pluginSource"], enable=False, ref=inputs["pluginRef"]) + disabled = PluginManager() + disabled.discover_and_load() + assert not disabled.get_portable_mcp_servers(), "Install enabled plugin without consent" + assert not any(entry["name"].endswith(":durable-work") for entry in disabled.list_plugin_skill_metadata()) + cmd_enable("openfunctions") + enabled = PluginManager() + enabled.discover_and_load() + skill_metadata = [entry for entry in enabled.list_plugin_skill_metadata() if entry["name"].endswith(":durable-work")] + assert len(skill_metadata) == 1, enabled.list_plugin_skill_metadata() + assert skill_metadata[0]["name"].startswith("agent-plugin-openfunctions-"), skill_metadata + assert enabled.find_plugin_skill(skill_metadata[0]["name"]).is_file() + servers = enabled.get_portable_mcp_servers() + assert servers["openfunctions"]["trust"] == "untrusted" + assert servers["openfunctions"]["command"] == "openfunction" + assert str(home) in servers["openfunctions"]["cwd"] + other_home = Path(inputs["scratch"]) / "profile-b" + other_home.mkdir() + other = PluginManager(scope_key=str(other_home)) + other.discover_and_load() + assert not other.get_portable_mcp_servers(), "Plugin crossed isolated profiles" + assert not any(entry["name"].endswith(":durable-work") for entry in other.list_plugin_skill_metadata()) + portable = "passed" + if inputs["mode"] == "work": + work = work_proof(servers) + evidence = { + "hermesCommit": inputs["pin"], "python": sys.version, + "pythonExecutable": sys.executable, "mcp": importlib.metadata.version("mcp"), + "openfunctionBinary": inputs["binary"], "mode": inputs["mode"], + "openfunctionTarball": inputs["tarball"], "openfunctionTarballSha256": inputs["tarballSha256"], + "nodeMcpSdk": inputs["nodeMcpSdk"], "optionalBackendsInstalled": inputs["optionalBackendsInstalled"], + "nodeRuntime": json.loads(subprocess.check_output(["node", "-p", "JSON.stringify({version:process.version,executable:process.execPath})"], text=True)), + "discoveredTools": names, "readResult": read_result, "approvedResult": approved, + "refusedResult": refused, "approvedEffects": 1, "refusalEffects": 0, + "portableManifestValidated": True, + "modelUrl": f"http://127.0.0.1:{local_model.server_port}/v1", "localModelRequests": len(model_requests), "modelTurn": turn, + "work": work, + "boundaries": {"ordinaryMcp": "passed", "portableInstall": portable, "portableWorkServer": "passed" if work else "not run", "localModelTurn": "passed", "durableWork": "passed" if work else "not run"}, + } + (Path(inputs["scratch"]) / "evidence.json").write_text(json.dumps(evidence, indent=2)) +finally: + local_model.shutdown() + shutdown_mcp_servers() diff --git a/test-client/fixtures/siftable-work-recovery-1.5.1.json b/test-client/fixtures/siftable-work-recovery-1.5.1.json new file mode 100644 index 0000000..e473db2 --- /dev/null +++ b/test-client/fixtures/siftable-work-recovery-1.5.1.json @@ -0,0 +1,392 @@ +{ + "schemaVersion": 1, + "status": "blocked", + "package": { + "name": "@siftable/mcp-server", + "version": "1.5.1", + "shasum": "baab327fe1f43543600c6040fed5905e200417f2", + "integrity": "sha512-61n1Hxk42MVSpRs215Ua/V1Px+mdFoJ6K/BKEFlqnHsAK57hmMP9OwhnRGVgGRqQugKqINOVIOdgwwpurMTeJg==" + }, + "checkedAt": "2026-10-12", + "scope": "Published npm client/server package only; no production queue operations or server database guarantees tested.", + "capabilities": { + "version": "siftable-npm-1.5.1-inspection-v1", + "keyedOperations": [], + "receiptLookup": [], + "missingReceiptProvesNotStarted": false, + "automaticRecoveryEnabled": false + }, + "common": { + "principal": "Hosted sessions require validated authenticated principalId and adapter-private WorkLeaseSessionStore. Public arguments cannot supply principal identity.", + "receipt": "Canonical structuredContent: kind=sift.action_receipt,schemaVersion=1, receiptId,completedAt,surface=mcp,binding,operation(identifier,schemaDigest),outcome,result,error; optional idempotency(key,payloadDigest,duplicateBehavior,disposition).", + "certainty": "Only a validated operation-matching rejected receipt is pre-handler rejection. failed includes handler exceptions and post-effect storage failures; it does not prove not-started.", + "apiKeyScope": null, + "apiRetentionWindowMs": null, + "receiptLookupEndpoint": null, + "missingReceiptInterpretation": "unknown", + "sessionStore": "Published default store is process-local, purges after24h since update or lease expiry. This is not API idempotency receipt retention. Production persistent custody behavior is outside this package.", + "workContractParser": "parseAgentWorkContract is internal in dist/vendor/agentWorkContract.js. No parseWorkContract or supported contract-parser export in published package.json exports." + }, + "operations": [ + { + "operation": "open", + "tool": "work_session_open", + "action": null, + "requestFields": [ + "workspaceId", + "workItemId", + "assignedAlias", + "claimOwner", + "leaseSeconds", + "idempotencyKey" + ], + "requiredFields": [ + "claimOwner", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": null, + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "start", + "tool": "work_session_transition", + "action": "start", + "requestFields": [ + "sessionHandle", + "action", + "leaseSeconds", + "blockedReason", + "failureReason", + "resultSummary", + "artifactRefs", + "verificationResults", + "durableMemory", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "action", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": null, + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "heartbeat", + "tool": "work_session_transition", + "action": "heartbeat", + "requestFields": [ + "sessionHandle", + "action", + "leaseSeconds", + "blockedReason", + "failureReason", + "resultSummary", + "artifactRefs", + "verificationResults", + "durableMemory", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "action", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": null, + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "complete", + "tool": "work_session_transition", + "action": "complete", + "requestFields": [ + "sessionHandle", + "action", + "leaseSeconds", + "blockedReason", + "failureReason", + "resultSummary", + "artifactRefs", + "verificationResults", + "durableMemory", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "action", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": "action_and_key_only_no_payload_check", + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "review", + "tool": "work_session_transition", + "action": "review", + "requestFields": [ + "sessionHandle", + "action", + "leaseSeconds", + "blockedReason", + "failureReason", + "resultSummary", + "artifactRefs", + "verificationResults", + "durableMemory", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "action", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": "action_and_key_only_no_payload_check", + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "block", + "tool": "work_session_transition", + "action": "block", + "requestFields": [ + "sessionHandle", + "action", + "leaseSeconds", + "blockedReason", + "failureReason", + "resultSummary", + "artifactRefs", + "verificationResults", + "durableMemory", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "action", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": null, + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "fail", + "tool": "work_session_transition", + "action": "fail", + "requestFields": [ + "sessionHandle", + "action", + "leaseSeconds", + "blockedReason", + "failureReason", + "resultSummary", + "artifactRefs", + "verificationResults", + "durableMemory", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "action", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": "action_and_key_only_no_payload_check", + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "release", + "tool": "work_session_close", + "action": "release", + "requestFields": [ + "sessionHandle", + "resultSummary", + "idempotencyKey" + ], + "requiredFields": [ + "sessionHandle", + "idempotencyKey" + ], + "transportKeyField": "idempotencyKey", + "keyScope": null, + "retentionWindowMs": null, + "serverPayloadConflictContract": "not_verified", + "sessionClosedReplay": "action_and_key_only_no_payload_check", + "payloadSchemaId": "sift.work.lease-session.v1", + "identity": "principal_bound_opaque_session_handle", + "readback": "work_item_get reports current target state but does not establish operation receipt identity or prove non-execution", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + }, + { + "operation": "evidence", + "tool": "work_item_verification_evidence_submit", + "requestFields": [ + "workspaceId", + "workItemId", + "claimOwner", + "claimToken", + "planVersion", + "stepId", + "attemptId", + "outcome", + "exitCode", + "output", + "artifactRefs", + "environmentLabel", + "provenance", + "ranAt" + ], + "requiredFields": [ + "workItemId", + "planVersion", + "stepId", + "attemptId", + "outcome", + "environmentLabel" + ], + "transportKeyField": null, + "generatedKey": "ExfClient generates fresh work-verification-evidence key every invocation", + "evidenceIdentityField": "attemptId", + "identityScope": null, + "retentionWindowMs": null, + "conflictingAttemptPayloadContract": "not_verified", + "tokenCustody": "User evidence path excludes claimOwner/claimToken. Workspace-service raw custody is unsupported by default hosted work profile.", + "readback": "Verification plan/history exist; authoritative receipt/attempt identity lookup and exact duplicate matching are not established by published package.", + "retryAfterUnknown": "disabled", + "reconciliationAfterUnknown": "unknown" + } + ], + "sources": [ + { + "path": "package.json", + "purpose": "version and public export map" + }, + { + "path": "dist/workLeaseSessionTools.js", + "lines": [ + 1, + 40, + 130, + 227, + 282, + 312, + 337, + 363 + ], + "purpose": "required fields, custody, stable session handle, closed replay, compensating release and post-effect save failures" + }, + { + "path": "dist/exfClient.js", + "lines": [ + 642, + 696, + 702, + 719, + 724 + ], + "purpose": "API reads, caller keys for claim/transition and generated evidence key" + }, + { + "path": "dist/factory.js", + "lines": [ + 791, + 803, + 1446, + 1497, + 1527, + 1558 + ], + "purpose": "rejected versus failed/succeeded canonical action receipts" + }, + { + "path": "dist/vendor/agentWorkContract.js", + "lines": [ + 4, + 5, + 6 + ], + "purpose": "private parser name" + } + ], + "findings": [ + { + "id": "evidence-retry-key", + "severity": "blocker", + "finding": "submitWorkVerificationEvidence generates a new Idempotency-Key for every invocation; stable attemptId is present in input but server attempt uniqueness/conflict/reconciliation is unverified." + }, + { + "id": "closed-payload-drift", + "severity": "blocker", + "finding": "Closed hosted session replay tests terminalAction and terminalIdempotencyKey only. A changed resultSummary/artifactRefs payload with the same key is not compared before replaying stored result, despite canonical receipt duplicateBehavior advertising changed-payload rejection." + }, + { + "id": "api-receipt-contract", + "severity": "blocker", + "finding": "Client sends caller keys, but package does not establish server key namespace, dedup retention, identical/conflicting payload semantics for all operations, or authoritative receipt lookup." + }, + { + "id": "parser-export", + "severity": "blocker", + "finding": "Required canonical parser is not exposed by supported package exports; private vendored imports are not used." + }, + { + "id": "local-custody-not-production", + "severity": "limitation", + "finding": "Published default store is in memory. Hosted production session durability and retention require separate server implementation evidence." + } + ] +} diff --git a/test-client/fixtures/work-crash-fixture.ts b/test-client/fixtures/work-crash-fixture.ts new file mode 100644 index 0000000..b8f4a79 --- /dev/null +++ b/test-client/fixtures/work-crash-fixture.ts @@ -0,0 +1,28 @@ +/** Process-kill fixture only: real controller/backend/grants, paused at durable boundaries. */ +import { join } from "node:path"; +import { ExecutionJournal } from "../../src/framework/execution-journal.js"; +import { FileArtifactStore } from "../../src/framework/artifacts.js"; +import { ToolRegistry } from "../../src/framework/registry.js"; +import { LocalGrantStore } from "../../src/framework/work/grants.js"; +import { LocalWorkBackend } from "../../src/framework/work/local-backend.js"; +import { WorkAttemptController } from "../../src/framework/work/controller.js"; +import { createLocalFixtureBindings } from "../../src/framework/work/local-fixture.js"; +const root=process.env.OF_WORK_CRASH_ROOT!;const mode=process.env.OF_WORK_CRASH_MODE!; +const bindings=createLocalFixtureBindings({workspaceRoot:root,fixturePath:"fixture.json",verifierPath:"verify.mjs",...(mode === "step_after_effect" ? {afterEffect:async()=>stop()}: {})}); +const registry=new ToolRegistry();registry.registerAll(bindings.tools); +const backend=new LocalWorkBackend({rootDir:join(root,"backend"),backendId:"local-test",workspaceId:"test-workspace",workItems:[{workItemId:"job",contract:bindings.contract,plan:{id:"plan",version:1,state:"active",steps:[bindings.verificationStep]}}]}); +const controller=new WorkAttemptController({backend,registry,grantResolver:new LocalGrantStore({rootDir:join(root,"grants")}),journalRoot:join(root,"journals"),artifactRoot:join(root,"artifacts"),workspaceRoot:root,claimOwner:"test-owner",steps:bindings.steps,verificationBindings:bindings.verificationBindings,renewalSafetyMs:25}); +const stop=()=>{process.send?.("ready");Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);}; +const record=ExecutionJournal.prototype.record; +ExecutionJournal.prototype.record=function(event){if(event.type === "requested" && (mode === "verification_before_requested" && String(event.data.toolName).startsWith("_of_verify_") || mode === "step_before_requested" && event.data.toolName === "fixture_correct"))stop();record.call(this,event);const name=(event.data.receipt as {toolName?:string})?.toolName;if(mode === "verification_after_result" && event.type === "settled" && name?.startsWith("_of_verify_"))stop();if(mode === "verification_after_start" && event.type === "started" && name?.startsWith("_of_verify_"))stop();if(mode === "step_after_result" && event.type === "settled" && name === "fixture_correct")stop();if(mode === "readback_after_result" && event.type === "settled" && name?.startsWith("_of_readback_"))stop();}; +const put=FileArtifactStore.prototype.put; +FileArtifactStore.prototype.put=function(content,options){const artifact=put.call(this,content,options);if(mode === "verification_after_effect")stop();return artifact;}; +if(mode === "output_after_ledger") { + const recordOutput=LocalGrantStore.prototype.recordOutput; + LocalGrantStore.prototype.recordOutput=async function(...args){await recordOutput.apply(this,args);stop();}; +} +await controller.reconcile(); +if(mode === "output_after_ledger")await controller.step("inspect",{}); +else if(mode.startsWith("verification_"))await controller.verify("fixture-check"); +else {const inspected=await controller.step("inspect",{});await controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});} +throw new Error("Crash fixture failed to reach selected durable boundary"); diff --git a/test-client/provision-hermes-runtime.mjs b/test-client/provision-hermes-runtime.mjs new file mode 100644 index 0000000..d30bbc7 --- /dev/null +++ b/test-client/provision-hermes-runtime.mjs @@ -0,0 +1,25 @@ +/** Provision the pinned host only through Hermes's own managed runtime launcher. */ +import { spawn, execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +const flags = {}; +for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i]; + if (!["--hermes-root", "--runtime-dir", "--data-dir", "--out"].includes(key) || !process.argv[i + 1]) throw Error(`Invalid option ${key}`); + flags[key.slice(2)] = process.argv[i + 1]; +} +for (const key of ["hermes-root", "runtime-dir", "data-dir", "out"]) if (!flags[key] || !isAbsolute(flags[key])) throw Error(`Provisioning requires absolute --${key}`); +const commit = "7cc3fb8cb55898747dd1dfa56fda3e16ebba028e"; +if (execFileSync("git", ["rev-parse", "HEAD"], { cwd: flags["hermes-root"], encoding: "utf8" }).trim() !== commit) throw Error("Wrong Hermes checkout; expected pinned commit " + commit); +mkdirSync(flags["data-dir"], { recursive: true, mode: 0o700 }); +mkdirSync(flags["runtime-dir"], { recursive: true, mode: 0o700 }); +const child = spawn(join(flags["hermes-root"], "scripts/run-in-hermes-env"), ["python", "-c", "import sys; print(sys.executable)"], { cwd: flags["hermes-root"], env: { ...process.env, HERMES_HOME: flags["data-dir"], HERMES_RUNTIME_DIR: flags["runtime-dir"] }, stdio: ["ignore", "pipe", "inherit"] }); +let stdout = ""; +child.stdout.on("data", chunk => { stdout += chunk; }); +const status = await new Promise((accept, reject) => { child.once("error", reject); child.once("exit", (code, signal) => accept({ code, signal })); }); +if (status.code !== 0) throw Error(`Hermes PM provisioning failed (${status.code ?? status.signal}); host boundary remains unproven`); +const python = stdout.trim(); +if (!isAbsolute(python) || !existsSync(python) || python.includes("\n")) throw Error("Official launcher did not return one valid absolute managed interpreter path"); +mkdirSync(dirname(flags.out), { recursive: true }); +writeFileSync(flags.out, JSON.stringify({ schemaVersion: 1, hermesCommit: commit, hermesRoot: resolve(flags["hermes-root"]), python, runtimeDir: resolve(flags["runtime-dir"]), provisioningHome: resolve(flags["data-dir"]) }, null, 2), { mode: 0o600 }); +console.log(`Pinned Hermes runtime provisioned; interpreter record: ${flags.out}`); diff --git a/test-client/run-caller-convergence-tests.ts b/test-client/run-caller-convergence-tests.ts new file mode 100644 index 0000000..c3d3c61 --- /dev/null +++ b/test-client/run-caller-convergence-tests.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { defineAgent, runCrew } from "../src/framework/agents.js"; +import { llmStep, toolStep } from "../src/framework/workflows.js"; +import { defineTool, ok } from "../src/framework/tool.js"; +import type { AIAdapter, AdapterResponse, ChatMessage } from "../src/framework/adapters/types.js"; + +const schema = { type: "object", properties: {} } as const; +function adapter(responses: AdapterResponse[], history: ChatMessage[][] = []): AIAdapter { + return { + name: "test", model: "fixture", + async chat(messages) { history.push(structuredClone(messages)); return responses.shift() ?? { text: "done" }; }, + }; +} + +test("filtered agents retain root execution policy and preserve receipt references", async () => { + let invoked = false; let checked = 0; + const registry = new ToolRegistry({ policy: { authorize: ({ context }) => { + checked++; + assert.equal(context.actor?.id, "worker"); + assert.equal(context.scope?.workspaceId, "allowed"); + return { decision: "deny", reasonCodes: ["fixture_denied"] }; + } } }); + registry.register(defineTool({ name: "guarded_action", description: "Guarded operation", inputSchema: schema, handler: async () => { invoked = true; return ok({}); } })); + const worker = defineAgent({ name: "worker", role: "Worker", goal: "Try the guarded action", tools: ["guarded_action"] }); + const result = await worker.run("try", adapter([ + { toolCall: { id: "one", name: "guarded_action", args: {} } }, { text: "Denied" }, + ]), registry, undefined, { executionContext: { scope: { workspaceId: "allowed" } } }); + assert.equal(invoked, false); assert.equal(checked, 1); + assert.equal(result.toolCalls[0].result.success, false); + assert.ok(result.toolCalls[0].executionReceiptId); + assert.equal(result.run.executionReceiptRefs?.[0], result.toolCalls[0].executionReceiptId); +}); + +test("agent and workflow callers normalize hooks once and retain model-call order", async () => { + let serialized = 0; const history: ChatMessage[][] = []; const registry = new ToolRegistry(); + for (const name of ["first_read", "second_read"]) registry.register(defineTool({ + name, description: "Serialize read result", inputSchema: schema, + executionMode: () => "parallel-safe", + handler: async () => ok({ toJSON() { serialized++; return { name }; } }), + })); + const calls = [ + { id: "first", name: "first_read", args: {} }, { id: "second", name: "second_read", args: {} }, + ]; + const worker = defineAgent({ name: "reader", role: "Reader", goal: "Read both" }); + const result = await worker.run("read", adapter([{ toolCalls: calls }, { text: "done" }], history), registry); + assert.equal(serialized, 2); + assert.deepEqual(result.toolCalls.map((call) => call.name), ["first_read", "second_read"]); + assert.deepEqual(history[1].filter((message) => message.role === "tool").map((message) => message.toolCallId), ["first", "second"]); + await llmStep(adapter([{ toolCalls: calls }, { text: "done" }]), registry, "{{input}}")("read"); + assert.equal(serialized, 4); + await toolStep(registry, "first_read")({}); assert.equal(serialized, 5); +}); + +test("synthetic crew delegation inherits private reservation and trusted workspace context", async () => { + const registry = new ToolRegistry(); let workspace: string | undefined; const observed: string[] = []; + registry.register(defineTool({ name: "worker_action", description: "Perform worker action", inputSchema: schema, + handler: async (_params, context) => { workspace = context?.scope?.workspaceId; observed.push("action"); return ok({ done: true }); }, + })); + const leader = defineAgent({ name: "leader", role: "Leader", goal: "Delegate", tools: ["delegate_to_worker"] }); + const worker = defineAgent({ name: "worker", role: "Worker", goal: "Act", tools: ["worker_action"] }); + const mock: AIAdapter = { + name: "test", model: "fixture", + async chat(messages, _registry, options) { + const role = options?.systemPrompt?.includes("You are Leader") ? "leader" : "worker"; + if (messages.some((message) => message.role === "tool")) return { text: `${role} done` }; + return role === "leader" + ? { toolCall: { id: "delegate", name: "delegate_to_worker", args: { task: "act" } } } + : { toolCall: { id: "worker", name: "worker_action", args: {} } }; + }, + }; + // The explicit helper used by the synthetic handler must re-enter the same + // reservation; the exclusive delegation call would otherwise wait on itself. + const result = await runCrew({ agents: [leader, worker], delegation: true }, "work", mock, registry); + assert.match(result.output, /worker done/); + assert.deepEqual(observed, ["action", "action"]); + // Also exercise trusted context inheritance without the crew facade. + const projection = registry.project({}); + projection.register(defineTool({ name: "nested_action", description: "Delegate internally", inputSchema: schema, + handler: async () => projection.withChildExecution(() => registry.execute("worker_action", {})), + })); + await projection.executeDetailed("nested_action", {}, { scope: { workspaceId: "workspace-A" } }); + assert.equal(workspace, "workspace-A"); +}); + +test("unclassified, invalid and throwing classifiers remain exclusive across direct projected calls", async () => { + for (const classifier of [undefined, () => { throw new Error("classifier failed"); }, (() => "untrusted-value") as unknown as () => "exclusive"]) { + const registry = new ToolRegistry(); const starts: string[] = []; + let releaseFirst!: () => void, releaseExclusive!: () => void; + const firstGate = new Promise((done) => { releaseFirst = done; }); + const exclusiveGate = new Promise((done) => { releaseExclusive = done; }); + registry.register(defineTool({ name: "safe_read", description: "Read without effects", inputSchema: schema, executionMode: "parallel-safe", + handler: async ({ first }) => { starts.push(first ? "read-first" : "read-last"); if (first) await firstGate; return ok({}); }, + })); + registry.register(defineTool({ name: "unclassified", description: "Conservatively serialized", inputSchema: schema, executionMode: classifier, + handler: async () => { starts.push("exclusive"); await exclusiveGate; return ok({}); }, + })); + const projection = registry.project({}); + const first = registry.executeDetailed("safe_read", { first: true }); + const exclusive = projection.executeDetailed("unclassified", {}); + const last = registry.executeDetailed("safe_read", {}); + await new Promise((done) => setImmediate(done)); + assert.deepEqual(starts, ["read-first"]); + releaseFirst(); await new Promise((done) => setImmediate(done)); + assert.deepEqual(starts, ["read-first", "exclusive"]); + releaseExclusive(); await Promise.all([first, exclusive, last]); + assert.deepEqual(starts, ["read-first", "exclusive", "read-last"]); + } +}); diff --git a/test-client/run-chat-execution-tests.ts b/test-client/run-chat-execution-tests.ts new file mode 100644 index 0000000..14d3883 --- /dev/null +++ b/test-client/run-chat-execution-tests.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env tsx +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createChatAgent } from "../src/framework/chat-agent.js"; +import { createChatAgentHttpServer } from "../src/framework/chat-agent-http.js"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { SessionKernel, InMemorySessionEventStore, type SessionEvent } from "../src/framework/session.js"; +import { RunExecutionError } from "../src/framework/runs.js"; +import { defineTool } from "../src/framework/tool.js"; +import type { AIAdapter, AdapterResponse, ChatMessage, ChatOptions } from "../src/framework/adapters/types.js"; +import type { ToolExecutionContext } from "../src/framework/execution-types.js"; + +function adapter(responses: AdapterResponse[], requests: ChatMessage[][] = [], options: ChatOptions[] = []): AIAdapter { + return { + name: "offline", model: "fixture", sessionStateKey: "fixture.state", + async chat(messages, _registry, config) { + requests.push(structuredClone(messages)); + options.push(config ?? {}); + return responses.shift() ?? { text: "done" }; + }, + }; +} +const definition = { name: "write", description: "Offline effect", inputSchema: { type: "object" as const, properties: { n: { type: "integer" as const } }, required: ["n"] } }; + +test("chat executor: policy rejection produces a bound v2 receipt without handler or execution start", async () => { + let calls = 0; + const registry = new ToolRegistry({ mode: "governed", policy: { authorize: () => ({ decision: "deny", reasonCodes: ["fixture_denied"] }) } }); + registry.register(defineTool({ ...definition, handler: async () => { calls++; return { success: true }; } })); + const agent = await createChatAgent({ tools: registry, memory: false, adapter: adapter([{ toolCall: { id: "denied", name: "write", args: { n: 1 } } }]) }); + const answer = await agent.chat("write"); + assert.equal(calls, 0); + assert.equal(answer.toolCalls[0].result.success, false); + const events = agent.getSessionEvents(); + assert.equal(events.filter((event) => event.type === "tool/execution-started").length, 0); + const settlement = events.find((event) => event.type === "tool/result"); + assert.ok(settlement?.type === "tool/result"); + assert.equal(settlement.schemaVersion, 2); + assert.equal(settlement.data.receipt?.phase, "not_started"); + assert.equal(settlement.data.receipt?.admission, "rejected"); + assert.equal(settlement.data.receipt?.callId, "denied"); +}); + +test("chat executor: malformed arguments never produce a fake start", async () => { + let calls = 0; + const tool = defineTool({ ...definition, handler: async () => { calls++; return { success: true }; } }); + const agent = await createChatAgent({ tools: [tool], memory: false, adapter: adapter([{ toolCall: { id: "invalid", name: "write", args: { n: "1" } } }]) }); + await agent.chat("write"); + assert.equal(calls, 0); + assert.equal(agent.getSessionEvents().filter((event) => event.type === "tool/execution-started").length, 0); +}); + +test("chat executor: records durable start before dispatch and preserves one normalization", async () => { + let normalized = 0; + let seen: ToolExecutionContext | undefined; + let agent: Awaited>; + const tool = defineTool({ ...definition, handler: async (_args, context) => { + seen = context; + assert.equal(agent.getSessionEvents().at(-1)?.type, "tool/execution-started"); + return { success: true, toJSON() { normalized++; return { success: true, data: { value: 1 } }; } }; + } }); + agent = await createChatAgent({ tools: [tool], memory: false, executionContext: { actor: { kind: "host", id: "trusted-host" }, authorityGrantRefs: ["grant-1"] }, adapter: adapter([{ toolCall: { id: "actual", name: "write", args: { n: 1 } } }]) }); + const result = await agent.chat("write", { executionContext: { scope: { workspaceId: "workspace-1" } } }); + assert.equal(normalized, 1); + assert.equal(seen?.actor?.id, "trusted-host"); + assert.equal(seen?.callId, "actual"); + assert.equal(seen?.scope?.workspaceId, "workspace-1"); + assert.equal(seen?.runId, result.run.manifest.runId); + const settled = agent.getSessionEvents().find((event) => event.type === "tool/result"); + assert.ok(settled?.type === "tool/result"); + const modelMessage = agent.getHistory().find((message) => message.role === "tool"); + assert.equal(modelMessage?.content, settled.data.modelContent); + assert.equal(settled.data.receipt?.phase, "settled"); +}); + +test("chat executor: HTTP identity comes from the trusted resolver rather than body fields", async (t) => { + let actor: string | undefined; + const tool = defineTool({ ...definition, handler: async (_args, context) => { actor = context?.actor?.id; return { success: true }; } }); + const agent = await createChatAgent({ tools: [tool], memory: false, adapter: adapter([{ toolCall: { id: "http", name: "write", args: { n: 1 } } }]) }); + const server = createChatAgentHttpServer(agent, { resolveExecutionContext: (request) => ({ actor: { kind: "host", id: String(request.headers["x-fixture-identity"]) } }) }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const response = await fetch(`http://127.0.0.1:${address.port}/chat`, { method: "POST", headers: { "Content-Type": "application/json", "x-fixture-identity": "authenticated-fixture" }, body: JSON.stringify({ message: "write", actor: { id: "forged" }, executionContext: { actor: { id: "forged" }, authorityGrantRefs: ["forged"] } }) }); + assert.equal(response.status, 200); + assert.equal(actor, "authenticated-fixture"); +}); + +test("history budget: retains complete tool exchanges and clears continuation atomically", async () => { + const requests: ChatMessage[][] = []; + const options: ChatOptions[] = []; + const state = { key: "fixture.state", continuationId: "continuation", fingerprint: "fingerprint" }; + const tool = defineTool({ ...definition, handler: async () => ({ success: true, data: { artifactRef: "artifact-1" } }) }); + const native = { key: "fixture.native", outputItems: [{ type: "function_call", id: "tool-native" }] }; + const agent = await createChatAgent({ tools: [tool], memory: false, historyBudget: { maxMessages: 6 }, adapter: adapter([ + { text: "old", sessionState: state }, + { toolCall: { id: "grouped", name: "write", args: { n: 1 } }, providerReplay: native, thinking: [{ signature: "exact" }], sessionState: state }, + { text: "tool done", sessionState: state }, + { text: "latest", sessionState: state }, + ], requests, options) }); + await agent.chat("old user"); + await agent.chat("tool user"); + await agent.chat("new user"); + const latest = requests.at(-1)!; + assert.equal(latest.length, 5); + assert.equal(latest[0].content, "tool user"); + assert.deepEqual(latest[1].providerReplay, native); + assert.deepEqual(latest[1].thinkingBlocks, [{ signature: "exact" }]); + assert.equal(latest[2].toolCallId, "grouped"); + assert.ok(String(latest[2].content).includes("artifact-1")); + assert.equal(options.at(-1)?.sessionState, undefined); + const compaction = agent.getSessionEvents().find((event) => event.type === "history/replaced" && event.data.reason === "model_history_budget"); + assert.ok(compaction?.type === "history/replaced"); + assert.equal(compaction.data.clearAdapterStates, true); + assert.ok(agent.getSessionEvents().some((event) => event.type === "message/appended" && event.data.message.content === "old user")); +}); + +test("history budget: complete active turns fail explicitly when irreducible rather than splitting calls", async () => { + let effects = 0; + const requests: ChatMessage[][] = []; + const tool = defineTool({ ...definition, handler: async () => { effects++; return { success: true, data: { value: "saved" } }; } }); + const agent = await createChatAgent({ tools: [tool], memory: false, historyBudget: { maxMessages: 2 }, adapter: adapter([{ toolCall: { id: "too-large", name: "write", args: { n: 1 } } }], requests) }); + await assert.rejects(agent.chat("write"), /history budget/); + assert.equal(effects, 1); + assert.equal(requests.length, 1); + assert.equal(agent.getHistory().filter((message) => message.role === "tool").length, 1); +}); + + +test("chat provenance: actual adapter usage and limits are recorded without inferred measurements", async () => { + const agent = await createChatAgent({ memory: false, adapter: adapter([{ text: "done", usage: { inputTokens: 17, outputTokens: 3, cachedInputTokens: 5 }, requestLimits: { maxOutputTokens: 100 } }]) }); + await agent.chat("measure"); + const event = agent.getSessionEvents().find((event) => event.type === "model/response-received"); + assert.ok(event?.type === "model/response-received"); + assert.equal(event.data.usage?.totalTokens, undefined); + assert.deepEqual(event.data.usage, { inputTokens: 17, outputTokens: 3, cachedInputTokens: 5 }); + assert.deepEqual(event.data.requestLimits, { maxOutputTokens: 100 }); +}); + + +test("chat cancellation: a bound v2 no-dispatch receipt supersedes durable started intent without false effects", async () => { + const cancellation = new AbortController(); + let handlerCalls = 0; + const registry = new ToolRegistry({ journal: { record(event) { + if (event.type === "started") cancellation.abort(new Error("cancel after durable intent before handler")); + } } }); + registry.register(defineTool({ ...definition, handler: async () => { handlerCalls++; return { success: true }; } })); + const agent = await createChatAgent({ memory: false, tools: registry, adapter: adapter([{ toolCall: { id: "cancelled-intent", name: "write", args: { n: 1 } } }]) }); + await assert.rejects(agent.chat("write", { signal: cancellation.signal }), (error: unknown) => { + assert.ok(error instanceof RunExecutionError); + assert.equal(error.run.cancellation?.effects, "none"); + assert.equal(error.toolEffects, undefined); + return true; + }); + assert.equal(handlerCalls, 0); + const events = agent.getSessionEvents(); + assert.ok(events.some((event) => event.type === "tool/execution-started")); + const settled = events.find((event) => event.type === "tool/result"); + assert.ok(settled?.type === "tool/result"); + assert.equal(settled.data.outcome, "failed"); + assert.equal(settled.data.receipt?.phase, "not_started"); + assert.equal(settled.data.receipt?.admission, "rejected"); + assert.equal(settled.data.recovered, undefined); + const mixed = events.map((event, index) => index === 0 ? { ...event, schemaVersion: 1 as const } : event) as SessionEvent[]; + const replayed = new SessionKernel({ sessionId: mixed[0].sessionId, store: new InMemorySessionEventStore(mixed) }); + assert.deepEqual(replayed.recoverInterrupted(), []); + assert.equal(handlerCalls, 0); + const replayedResult = replayed.getEvents().find((event) => event.type === "tool/result"); + assert.ok(replayedResult?.type === "tool/result"); + assert.equal(replayedResult.data.modelContent, settled.data.modelContent); +}); diff --git a/test-client/run-context-tests.ts b/test-client/run-context-tests.ts new file mode 100644 index 0000000..8549541 --- /dev/null +++ b/test-client/run-context-tests.ts @@ -0,0 +1,153 @@ +#!/usr/bin/env tsx +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { collectContextSnapshots, contextSnapshotReferences, formatContextSnapshots, invalidateContext, type ConnectedProvider, type ContextSnapshotInput } from "../src/framework/context.js"; +import { createChatAgent } from "../src/framework/chat-agent.js"; +import type { AIAdapter, ChatMessage } from "../src/framework/adapters/types.js"; + +const start = new Date("2026-10-12T12:00:00.000Z"); +function provider(buildSnapshot?: () => Promise): ConnectedProvider { + return { metadata: { id: "fixture", name: "Fixture", description: "Offline", capabilities: ["tasks"] }, contextScope: { principalId: "user-1", workspaceId: "workspace-1" }, createTools: () => [], buildSnapshot }; +} +function snapshot(content = "Task A"): ContextSnapshotInput { + return { providerId: "fixture", scope: { principalId: "user-1", workspaceId: "workspace-1" }, content, observedAt: start.toISOString(), revision: "revision-1", completeness: "complete", sourceRefs: [{ id: "task-a", revision: "revision-1" }], items: [{ kind: "observation", text: "Task A is open", sourceRefs: ["task-a"] }] }; +} + +test("context: immutable attributed snapshots distinguish source observation from local retrieval", async () => { + const source = snapshot(); + const connected = provider(async () => source); + const [captured] = await collectContextSnapshots([connected], { clock: () => new Date("2026-10-12T12:01:00.000Z") }); + assert.equal(captured.observedAt, start.toISOString()); + assert.equal(captured.retrievedAt, "2026-10-12T12:01:00.000Z"); + assert.equal(captured.freshness, "fresh"); + assert.equal(captured.scope.principalId, "user-1"); + assert.ok(Object.isFrozen(captured)); + source.content = "Changed externally"; + assert.equal(captured.content, "Task A"); + const ref = contextSnapshotReferences([captured])[0]; + assert.equal(ref.snapshotId, captured.snapshotId); + assert.equal(ref.contentDigest, captured.contentDigest); + assert.ok(!("content" in ref)); +}); + +test("context: legacy strings never gain invented upstream freshness or revision", async () => { + let calls = 0; + const connected = provider(); + connected.buildContext = async () => { calls++; return "legacy tasks"; }; + const [first] = await collectContextSnapshots([connected], { clock: () => start }); + const [second] = await collectContextSnapshots([connected], { clock: () => new Date(start.getTime() + 60_000) }); + assert.equal(calls, 1); + assert.equal(first.snapshotId, second.snapshotId); + assert.equal(first.freshness, "unknown"); + assert.equal(first.observedAt, undefined); + assert.equal(first.revision, undefined); +}); + +test("context: turn refresh changes captured identity/digest while prior evidence remains exact", async () => { + let content = "before"; + const connected = provider(async () => snapshot(content)); + const [first] = await collectContextSnapshots([connected], { freshness: "turn", clock: () => start }); + content = "after"; + const [second] = await collectContextSnapshots([connected], { freshness: "turn", clock: () => start }); + assert.notEqual(first.snapshotId, second.snapshotId); + assert.notEqual(first.contentDigest, second.contentDigest); + assert.equal(first.content, "before"); + assert.equal(second.content, "after"); +}); + +test("context: invalidation refreshes without treating failed retrieval as confirmed absence", async () => { + let failure = false; + const connected = provider(async () => { if (failure) throw new Error("private credential must not leak"); return snapshot(); }); + const [first] = await collectContextSnapshots([connected], { freshness: "invalidated", clock: () => start }); + failure = true; + assert.equal((await collectContextSnapshots([connected], { freshness: "invalidated", clock: () => start }))[0].snapshotId, first.snapshotId); + invalidateContext(connected); + const [stale] = await collectContextSnapshots([connected], { freshness: "invalidated", clock: () => start }); + assert.equal(stale.freshness, "stale"); + assert.equal(stale.content, first.content); + assert.ok(formatContextSnapshots([stale]).includes("stale")); + assert.ok(!JSON.stringify(stale).includes("private credential")); + const unavailable = provider(async () => { throw new Error("private credential"); }); + const [missing] = await collectContextSnapshots([unavailable], { clock: () => start }); + assert.equal(missing.freshness, "unavailable"); + assert.equal(missing.completeness, "unknown"); + assert.match(formatContextSnapshots([missing]), /unavailable/); +}); + +test("context: explicit upstream expiry marks a cached observation stale without inventing a reread", async () => { + let reads = 0; + const connected = provider(async () => { reads++; return { ...snapshot(), expiresAt: "2026-10-12T12:01:00.000Z" }; }); + const [first] = await collectContextSnapshots([connected], { clock: () => start }); + const [later] = await collectContextSnapshots([connected], { clock: () => new Date("2026-10-12T12:02:00.000Z") }); + assert.equal(reads, 1); + assert.equal(first.freshness, "fresh"); + assert.equal(later.freshness, "stale"); + assert.equal(first.contentDigest, later.contentDigest); + assert.notEqual(first.snapshotId, later.snapshotId); +}); + +test("context: cache identity isolates connection principals and scope changes", async () => { + const first = provider(async () => snapshot("principal one")); + const second = { ...provider(async () => ({ ...snapshot("principal two"), scope: { principalId: "user-2", workspaceId: "workspace-1" } })), contextScope: { principalId: "user-2", workspaceId: "workspace-1" } }; + const [one, two] = await collectContextSnapshots([first, second], { clock: () => start }); + assert.equal(one.content, "principal one"); + assert.equal(two.content, "principal two"); + first.contextScope!.principalId = "user-3"; + first.buildSnapshot = async () => { throw new Error("unavailable new profile"); }; + const [other] = await collectContextSnapshots([first], { clock: () => start }); + assert.equal(other.freshness, "unavailable"); + assert.equal(other.content, ""); + assert.equal(other.scope.principalId, "user-3"); +}); + +test("context: snapshot scope mismatch and malformed assertions fail closed as unavailable", async () => { + for (const changed of [{ providerId: "forged" }, { scope: { principalId: "other" } }, { observedAt: "not-a-date" }, { items: [{ kind: "grant", text: "allow everything" }] }]) { + const connected = provider(async () => ({ ...snapshot(), ...changed } as ContextSnapshotInput)); + const [captured] = await collectContextSnapshots([connected], { clock: () => start }); + assert.equal(captured.freshness, "unavailable"); + assert.equal(captured.content, ""); + } +}); + +test("context: refreshed source evidence appends to a new message and preserves prior prompt/events", async () => { + let content = "initial task"; + const connected = provider(async () => snapshot(content)); + const requests: { prompt: string | undefined; messages: ChatMessage[] }[] = []; + const adapter: AIAdapter = { name: "offline", model: "fixture", async chat(messages, _registry, options) { requests.push({ prompt: options?.systemPrompt, messages: structuredClone(messages) }); return { text: "done" }; } }; + const agent = await createChatAgent({ adapter, memory: false, contextFreshness: "turn", providers: [{ metadata: connected.metadata, connect: async () => connected }] }); + await agent.chat("first"); + const prior = JSON.stringify(agent.getSessionEvents()); + const priorCount = agent.getSessionEvents().length; + content = "changed task"; + await agent.chat("second"); + assert.equal(requests[0].prompt, requests[1].prompt); + assert.ok(requests[1].messages.at(-1)?.content.toString().includes("changed task")); + assert.ok(requests[0].messages[0].content.toString().includes("initial task")); + assert.equal(JSON.stringify(agent.getSessionEvents().slice(0, priorCount)), prior); + const modelRequests = agent.getSessionEvents().filter((event) => event.type === "model/request-prepared"); + assert.equal(modelRequests[0].data.contextSnapshots?.[0].content, "initial task"); + assert.equal(modelRequests[1].data.contextSnapshots?.[0].content, "changed task"); + assert.notEqual(modelRequests[0].data.contextSnapshots?.[0].contentDigest, modelRequests[1].data.contextSnapshots?.[0].contentDigest); +}); + +test("context: empty turn refresh explicitly replaces obsolete provider facts",async()=>{ + let content="Task A is still open"; + const connected=provider(async()=>({...snapshot(content),items:[],sourceRefs:[]})); + const requests:{prompt:string|undefined;messages:ChatMessage[]}[]=[]; + const adapter:AIAdapter={name:"offline",model:"fixture",async chat(messages,_registry,options){requests.push({prompt:options?.systemPrompt,messages:structuredClone(messages)});return {text:"done"};}}; + const agent=await createChatAgent({adapter,memory:false,contextFreshness:"turn",providers:[{metadata:connected.metadata,connect:async()=>connected}]}); + try { + await agent.chat("first");content="";await agent.chat("second"); + assert.equal(requests[0].prompt,requests[1].prompt); + const message=String(requests[1].messages.at(-1)!.content); + assert.match(message,/No context text or items were returned/);assert.match(message,/in place of earlier context from this provider/); + const event=agent.getSessionEvents().filter(event=>event.type === "model/request-prepared").at(-1)!; + assert.equal(event.data.contextSnapshots![0].content,""); + }finally{await agent.destroy();} +}); + +test("context: structured items survive when the text field is empty",async()=>{ + const [captured]=await collectContextSnapshots([provider(async()=>snapshot(""))]); + assert.match(formatContextSnapshots([captured]),/\[observation\] Task A is open/); + assert.doesNotMatch(formatContextSnapshots([captured]),/No context text or items were returned/); +}); diff --git a/test-client/run-durability-tests.ts b/test-client/run-durability-tests.ts new file mode 100644 index 0000000..b56d585 --- /dev/null +++ b/test-client/run-durability-tests.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { appendFileSync, chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { ExecutionJournal } from "../src/framework/execution-journal.js"; +import { JournalConcurrencyError, JournalInvariantError, SecureJsonlJournal, type JournalEnvelope } from "../src/framework/journal-file.js"; +import { ArtifactQuotaError, FileArtifactStore } from "../src/framework/artifacts.js"; +import { digestJson } from "../src/framework/session.js"; +import { WorkAttemptJournal } from "../src/framework/work/journal.js"; + +function temporary(t:{after(fn:()=>void):void}):string {const root=mkdtempSync(join(tmpdir(),"openfunction-durability-"));chmodSync(root,0o700);t.after(()=>rmSync(root,{recursive:true,force:true}));return root;} +function execution(root:string):ExecutionJournal {return new ExecutionJournal({rootDir:root,streamId:"run",scopeDigest:"sha256:scope"});} +const started=(journal:ExecutionJournal) => { + journal.record({type:"requested",executionId:"call",data:{inputDigest:"digest",capabilityDigest:"capability"}}); + journal.record({type:"admitted",executionId:"call",data:{policyRef:"policy"}}); + journal.record({type:"started",executionId:"call",data:{}}); +}; +function done(journal:ExecutionJournal):void {journal.record({type:"settled",executionId:"call",data:{receipt:{schemaVersion:1,executionId:"call",phase:"settled",admission:"allowed",inputDigest:"digest",capabilityDigest:"capability",executionOutcome:"succeeded",resultDigest:digestJson({success:true,data:{changed:true}})},result:{success:true,data:{changed:true}},modelContent:JSON.stringify({success:true,data:{changed:true}})}});} + +if(process.env.OF_DURABILITY_CHILD){ + const root=process.env.OF_DURABILITY_ROOT!;const boundary=process.env.OF_DURABILITY_CHILD; + const journal=execution(root); + if(boundary === "writer") { + const file=new SecureJsonlJournal({rootDir:root,streamId:"cas",scopeDigest:"scope",schemaVersion:1}); + process.on("message",()=>{try {file.append({schemaVersion:1,streamId:"cas",scopeDigest:"scope",seq:1},0);process.send?.("appended");}catch(error){process.send?.(error instanceof JournalConcurrencyError ? "conflict" : "unexpected");}}); + } + else if(boundary === "owner")journal.acquireOwnership(); + else if(boundary === "recovery")journal.recover(); + else { + journal.record({type:"requested",executionId:"call",data:{inputDigest:"digest",capabilityDigest:"capability"}}); + if(boundary !== "before_admission") { + journal.record({type:"admitted",executionId:"call",data:{policyRef:"policy"}}); + journal.record({type:"started",executionId:"call",data:{}}); + if(boundary !== "after_start")writeFileSync(join(root,"effect"),"1",{mode:0o600}); + if(boundary === "after_result")done(journal); + } + } + process.send?.("ready");setInterval(()=>{},1000); +} else { + test("immutable snapshots, sequence CAS and scope/version binding",t=>{ + const root=temporary(t);const file=new SecureJsonlJournal({rootDir:root,streamId:"one",scopeDigest:"scope",schemaVersion:1}); + const event={schemaVersion:1,streamId:"one",scopeDigest:"scope",seq:1,data:{value:1}};file.append(event,0);event.data.value=2; + assert.equal(file.read()[0]!.data.value,1);assert.ok(Object.isFrozen(file.read()[0]!.data)); + assert.throws(()=>file.append({...event,seq:2},0),JournalConcurrencyError); + assert.throws(()=>new SecureJsonlJournal({rootDir:root,streamId:"one",scopeDigest:"changed",schemaVersion:1}).read(),JournalInvariantError); + assert.throws(()=>new SecureJsonlJournal({rootDir:root,streamId:"one",scopeDigest:"scope",schemaVersion:2}).read(),JournalInvariantError); + }); + test("torn final fragment recovers; malformed committed lines fail closed",t=>{ + const root=temporary(t);const journal=execution(root);started(journal); + const path=join(root,readdirSync(root).find(name=>name.endsWith(".jsonl"))!); + appendFileSync(path,'{"incomplete":');assert.equal(execution(root).recover()[0]!.state,"unknown");done(journal); + assert.equal(execution(root).recover()[0]!.state,"settled");assert.equal(readFileSync(path,"utf8").includes("incomplete"),false); + appendFileSync(path,"not json\n");assert.throws(()=>execution(root),JournalInvariantError); + }); + test("unsafe roots, ancestor links, file links and weak permissions fail closed",t=>{ + const root=temporary(t);const privateDir=join(root,"private");mkdirSync(privateDir,{mode:0o700}); + const link=join(root,"link");symlinkSync(privateDir,link);assert.throws(()=>execution(`${link}/`),JournalInvariantError);assert.throws(()=>execution(join(link,"child")),JournalInvariantError); + chmodSync(privateDir,0o755);assert.throws(()=>execution(privateDir),JournalInvariantError);chmodSync(privateDir,0o700); + const digest=createHash("sha256").update("run").digest("hex");symlinkSync(join(root,"other"),join(privateDir,`${digest}.jsonl`));assert.throws(()=>execution(privateDir),JournalInvariantError); + }); + test("authority custody and illegal transitions are rejected before append",t=>{ + const journal=execution(temporary(t)); + assert.throws(()=>journal.record({type:"started",executionId:"x",data:{}}),JournalInvariantError); + assert.throws(()=>journal.record({type:"requested",executionId:"x",data:{claimToken:"secret"}}),JournalInvariantError); + journal.record({type:"requested",executionId:"x",data:{}}); + assert.throws(()=>journal.record({type:"rejected",executionId:"x",data:{receipt:{phase:"settled"}}}),JournalInvariantError); + journal.record({type:"rejected",executionId:"x",data:{receipt:{schemaVersion:1,executionId:"x",phase:"not_started",admission:"rejected",executionOutcome:"failed",resultDigest:digestJson({success:false,error:"denied"})},result:{success:false,error:"denied"},modelContent:JSON.stringify({success:false,error:"denied"})}}); + assert.equal(journal.recover()[0]!.state,"settled"); + }); + test("work operations remain unresolved until receipt references settle them",t=>{ + const root=temporary(t);const journal=new WorkAttemptJournal({rootDir:root,attemptId:"attempt",scopeDigest:"scope"}); + journal.record({type:"prepared",data:{contractDigest:"contract"}}); + journal.record({type:"operation_started",data:{operationId:"op",kind:"step"}}); + assert.deepEqual(journal.recover().unresolvedOperationIds,["op"]); + assert.throws(()=>journal.record({type:"step",data:{result:"duplicated"}}),JournalInvariantError); + journal.record({type:"operation_settled",data:{operationId:"op"},executionId:"call",receiptRef:"receipt"}); + assert.deepEqual(journal.recover().unresolvedOperationIds,[]); + const reopened=new WorkAttemptJournal({rootDir:root,attemptId:"attempt",scopeDigest:"scope"});assert.deepEqual(reopened.recover(),journal.recover()); + }); + test("artifact quota, bounded reads, immutable output and digest checks",t=>{ + const root=temporary(t);const store=new FileArtifactStore({rootDir:root,quotaBytes:8,maxReadBytes:4});const ref=store.put("abcdef",{mediaType:"text/plain"}); + assert.equal(lstatSync(join(root,`${ref.id}.artifact`)).mode&0o777,0o600); + assert.throws(()=>store.read(ref.id),JournalInvariantError);const bytes=store.read(ref.id,{offset:1,length:3});assert.equal(bytes.toString(),"bcd");bytes.fill(0);assert.equal(store.read(ref.id,{offset:1,length:3}).toString(),"bcd"); + assert.throws(()=>store.read("../secret"),JournalInvariantError);assert.throws(()=>store.put("xyz"),ArtifactQuotaError); + const path=join(root,`${ref.id}.artifact`);const record=JSON.parse(readFileSync(path,"utf8"));record.reference.sha256="0".repeat(64);writeFileSync(path,JSON.stringify(record));assert.throws(()=>store.metadata(ref.id),JournalInvariantError); + }); + for(const boundary of ["before_admission","after_start","after_effect","after_result"]){ + test(`SIGKILL ${boundary} recovers without repeating an effect`,async t=>{ + const root=temporary(t);const child=await readyChild(root,boundary);await killChild(child); + const journal=execution(root);const recovered=journal.recover(); + assert.equal(recovered[0]!.state,boundary === "before_admission" ? "not_started" : boundary === "after_result" ? "settled" : "unknown"); + if(boundary === "after_result")assert.equal(recovered[0]!.settled!.modelContent,JSON.stringify({success:true,data:{changed:true}})); + const effect=existsSync(join(root,"effect")) ? readFileSync(join(root,"effect"),"utf8") : undefined; + const recoveryChild=await readyChild(root,"recovery");await killChild(recoveryChild); + assert.deepEqual(execution(root).recover(),recovered);assert.equal(existsSync(join(root,"effect")) ? readFileSync(join(root,"effect"),"utf8") : undefined,effect); + }); + } + test("cross-process compare-and-append admits only one concurrent writer",async t=>{ + const root=temporary(t);const one=await readyChild(root,"writer");const two=await readyChild(root,"writer"); + const status=(child:ReturnType)=>new Promise(resolve=>{child.once("message",value=>resolve(String(value)));child.send?.("go");}); + const statuses=await Promise.all([status(one),status(two)]);await Promise.all([killChild(one),killChild(two)]); + assert.deepEqual(statuses.sort(),["appended","conflict"]); + const file=new SecureJsonlJournal({rootDir:root,streamId:"cas",scopeDigest:"scope",schemaVersion:1});assert.equal(file.read().length,1); + }); + test("a live process owner cannot be stolen; dead owner is safely recovered",async t=>{ + const root=temporary(t);const child=await readyChild(root,"owner");assert.throws(()=>execution(root).acquireOwnership(),JournalConcurrencyError); + await killChild(child);const journal=execution(root);const owner=journal.acquireOwnership();assert.throws(()=>journal.acquireOwnership(),JournalConcurrencyError);owner.release();journal.acquireOwnership().release(); + }); +} +function readyChild(root:string,boundary:string):Promise> { + return new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,["--import","tsx",fileURLToPath(import.meta.url)],{env:{...process.env,OF_DURABILITY_CHILD:boundary,OF_DURABILITY_ROOT:root},stdio:["ignore","ignore","pipe","ipc"]}); + let error="";child.stderr?.on("data",chunk=>error+=chunk);const timeout=setTimeout(()=>{child.kill("SIGKILL");reject(new Error(`Child ${boundary} timed out: ${error}`));},10000); + child.once("message",()=>{clearTimeout(timeout);resolve(child);});child.once("error",error=>{clearTimeout(timeout);reject(error);});child.once("exit",code=>{clearTimeout(timeout);reject(new Error(`Child exited before ready ${code}: ${error}`));}); + }); +} +function killChild(child:ReturnType):Promise{return new Promise(resolve=>{child.once("exit",()=>resolve());child.kill("SIGKILL");});} diff --git a/test-client/run-execution-tests.ts b/test-client/run-execution-tests.ts new file mode 100644 index 0000000..a913940 --- /dev/null +++ b/test-client/run-execution-tests.ts @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { capabilityDigest } from "../src/framework/executor.js"; +import { ExecutionJournal } from "../src/framework/execution-journal.js"; +import { defineTool, ok } from "../src/framework/tool.js"; +import type { ToolDefinition } from "../src/framework/types.js"; +import type { ExecutionJournalEvent, ToolExecutionContext } from "../src/framework/execution-types.js"; + +function fixture(handler: ToolDefinition["handler"], extra: Partial = {}) { + return defineTool({ name: "fixture_effect", description: "An independently observed fixture effect", + inputSchema: { type: "object", properties: { value: { type: "integer", minimum: 1 } }, required: ["value"], additionalProperties: false }, + handler, ...extra }); +} + +test("admission rejects malformed arguments, unknown tools, and pre-start cancellation without effects", async () => { + let effects = 0; + const registry = new ToolRegistry(); + registry.register(fixture(async () => { effects++; return ok(effects); })); + const signal = AbortSignal.abort(); + for (const [name, args, context] of [ + ["fixture_effect", { value: 0 }, {}], ["missing", { value: 1 }, {}], + ["fixture_effect", { value: 1 }, { signal }], ["fixture_effect", null, {}], + ["fixture_effect", { value: Number.POSITIVE_INFINITY }, {}], + ] as [string, Record, ToolExecutionContext][]) { + const execution = await registry.executeDetailed(name, args, context); + assert.equal(execution.receipt.phase, "not_started"); + assert.equal(execution.receipt.admission, "rejected"); + } + assert.equal(effects, 0); +}); + +test("authority references supplied without a validating policy cannot authorize required tools", async () => { + let effects = 0; + const registry = new ToolRegistry(); + registry.register(fixture(async () => { effects++; return ok(effects); }, { + contract: { version: "1", useWhen: [], doNotUseWhen: [], sideEffects: ["fixture"], commitmentClass: "C1", idempotency: "non_idempotent", requiresAuthority: true }, + })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }, { actor: { kind: "local_user", id: "test" }, authorityGrantRefs: ["model-made-reference"] }); + assert.equal(execution.receipt.phase, "not_started"); + assert.ok(execution.receipt.reasonCodes.includes("approval_required")); + assert.equal(effects, 0); +}); + +test("projected callers retain governed policy and reject captured stale catalog", async () => { + let effects = 0; + const registry = new ToolRegistry({ mode: "governed", policy: { authorize: ({ context }) => ({ decision: context.actor?.id === "trusted" ? "allow" : "deny" }) } }); + const tool = fixture(async () => { effects++; return ok(effects); }); + registry.register(tool); + const projected = registry.project({ names: [tool.name] }); + assert.equal((await projected.executeDetailed(tool.name, { value: 1 })).receipt.phase, "not_started"); + registry.unregister(tool.name); + registry.register({ ...tool, description: "Changed reviewed implementation specification" }); + assert.equal((await projected.executeDetailed(tool.name, { value: 1 }, { actor: { kind: "host", id: "trusted" } })).receipt.phase, "not_started"); + assert.equal((await projected.executeDetailed(tool.name, { value: 1 }, { + actor: { kind: "host", id: "trusted" }, + expectedCapabilityDigest: capabilityDigest(registry.get(tool.name)!), + })).receipt.phase, "not_started", "Caller expectations cannot replace the projected catalog ceiling"); + assert.equal(effects, 0); +}); + +test("removed tools are unavailable from existing projections", async () => { + const registry = new ToolRegistry(); + registry.register(fixture(async () => ok("effect"))); + const projected = registry.project({}); + registry.unregister("fixture_effect"); + assert.equal((await projected.executeDetailed("fixture_effect", { value: 1 })).receipt.phase, "not_started"); +}); + +test("projection policies cannot extend expired root authority or erase a valid binding", async () => { + let effects = 0; + const registry = new ToolRegistry({ mode: "governed", clock: () => 1000, policy: { + authorize: () => ({ decision: "allow", authorityGrantRefs: ["root-grant"], expiresAt: 1 }), + } }); + registry.register(fixture(async () => { effects++; return ok(effects); })); + const projected = registry.project({}, { authorize: () => ({ decision: "allow", expiresAt: 5000 }) }); + assert.equal((await projected.executeDetailed("fixture_effect", { value: 1 })).receipt.phase, "not_started"); + assert.equal(effects, 0); + const bound = new ToolRegistry({ policy: { authorize: () => ({ decision: "allow", authorizationContextId: "trusted-policy-binding" }) } }); + bound.register(fixture(async () => ok("effect"), { + contract: { version: "1", useWhen: [], doNotUseWhen: [], sideEffects: ["fixture"], commitmentClass: "C1", idempotency: "non_idempotent", requiresAuthority: true }, + })); + assert.equal((await bound.project({}, { authorize: () => ({ decision: "allow" }) }).executeDetailed("fixture_effect", { value: 1 })).outcome, "succeeded"); +}); + +test("queued invocation reauthorizes after grant revocation", async () => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let allowed = true; + let effects = 0; + const registry = new ToolRegistry({ mode: "governed", policy: { authorize: () => ({ decision: allowed ? "allow" : "deny" }) } }); + registry.register(fixture(async ({ value }) => { effects++; if (value === 1) await held; return ok(value); })); + const first = registry.executeDetailed("fixture_effect", { value: 1 }); + // Wait for the independently visible first effect, without timing assumptions. + while (effects === 0) await new Promise(resolve => setImmediate(resolve)); + const second = registry.executeDetailed("fixture_effect", { value: 2 }); + await new Promise(resolve => setImmediate(resolve)); + allowed = false; + release(); + assert.equal((await first).outcome, "succeeded"); + assert.equal((await second).receipt.phase, "not_started"); + assert.equal(effects, 1); +}); + +test("serialization hooks execute once and the journal, model, and caller agree", async () => { + let hooks = 0; + const events: ExecutionJournalEvent[] = []; + const registry = new ToolRegistry({ journal: { record: event => { events.push(event); } } }); + registry.register(fixture(async () => ok({ toJSON() { hooks++; return { committed: true }; } }))); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(hooks, 1); + assert.deepEqual(JSON.parse(execution.modelContent), execution.result); + assert.deepEqual(events.at(-1)?.data.result, execution.result); + assert.equal(events.at(-1)?.data.modelContent, execution.modelContent); +}); + +test("revocation during durable start prevents handler entry and retains a not-started receipt", async () => { + let allowed = true; + let effects = 0; + const registry = new ToolRegistry({ mode: "governed", + policy: { authorize: () => ({ decision: allowed ? "allow" : "deny" }) }, + journal: { record: event => { if (event.type === "started") allowed = false; } }, + }); + registry.register(fixture(async () => { effects++; return ok(effects); })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(effects, 0); + assert.equal(execution.receipt.phase, "not_started"); + assert.equal(execution.receipt.admission, "rejected"); +}); + +test("policy I/O failure after durable intent is not an unknown physical effect", async () => { + let calls = 0; + let effects = 0; + const registry = new ToolRegistry({ mode: "governed", policy: { + authorize: () => { calls++; if (calls === 3) throw new Error("Policy backend disconnected"); return { decision: "allow" }; }, + } }); + registry.register(fixture(async () => { effects++; return ok(effects); })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(effects, 0); + assert.equal(execution.receipt.phase, "not_started"); + assert.equal(execution.outcome, "failed"); +}); + +test("throws and invalid outputs remain unknown after independently observed effects", async () => { + for (const behavior of ["throw", "bigint", "cycle", "schema"] as const) { + let effect = false; + const registry = new ToolRegistry(); + registry.register(fixture(async () => { + effect = true; + if (behavior === "throw") throw new Error("Response lost after committing"); + if (behavior === "bigint") return ok(BigInt(1)); + if (behavior === "cycle") { const cycle: Record = {}; cycle.self = cycle; return ok(cycle); } + return ok({ invalid: true }); + }, behavior === "schema" ? { outputSchema: { type: "object", properties: { changed: { type: "boolean" } }, required: ["changed"] } } : {})); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(effect, true); + assert.equal(execution.outcome, "unknown"); + assert.notEqual(execution.receipt.phase, "not_started"); + assert.ok(execution.receipt.reconciliation); + } +}); + +test("late cancellation preserves a committed canonical result and reaches the handler", async () => { + const controller = new AbortController(); + let boundSignal: AbortSignal | undefined; + const registry = new ToolRegistry(); + registry.register(fixture(async (_params, context) => { + boundSignal = context?.signal; + controller.abort(); + return ok({ committed: true }); + })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }, { signal: controller.signal }); + assert.equal(boundSignal?.aborted, true); + assert.equal(execution.result.success, true); + assert.equal(execution.outcome, "succeeded"); + assert.equal(execution.receipt.cancellationObserved, true); +}); + +test("known failures retain certainty with an output schema and malformed rich payloads stay unknown", async () => { + const registry = new ToolRegistry(); + registry.register(fixture(async () => ({ success: false, error: "Independently rejected before mutation" }), { + outputSchema: { type: "object", properties: { committed: { type: "boolean" } }, required: ["committed"] }, + })); + assert.equal((await registry.executeDetailed("fixture_effect", { value: 1 })).outcome, "failed"); + registry.register(fixture(async () => ({ success: true, structuredContent: null } as unknown as Awaited>))); + assert.equal((await registry.executeDetailed("fixture_effect", { value: 1 })).outcome, "unknown"); +}); + +test("journal failure before handler entry produces no effect", async () => { + let effects = 0; + const registry = new ToolRegistry({ logger: {}, journal: { record: event => { if (event.type === "started") throw new Error("Disk unavailable"); } } }); + registry.register(fixture(async () => { effects++; return ok(effects); })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(effects, 0); + assert.equal(execution.receipt.phase, "not_started"); +}); + +test("result persistence failure preserves useful data and truthful delivery uncertainty", async () => { + let effects = 0; + const registry = new ToolRegistry({ logger: {}, journal: { record: event => { if (event.type === "settled") throw new Error("Disk unavailable"); } } }); + registry.register(fixture(async () => { effects++; return ok({ committed: true }); })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(effects, 1); + assert.equal(execution.outcome, "unknown"); + assert.deepEqual(execution.result.data, { committed: true }); + assert.equal(execution.receipt.errorCategory, "journal_delivery_failed"); +}); + +test("durable recorded execution reopens exactly and recovery is effect-free", async () => { + const rootDir = mkdtempSync(join(tmpdir(), "of-executor-")); + try { + const journal = new ExecutionJournal({ rootDir, streamId: "test", scopeDigest: "workspace-test" }); + let effects = 0; + const registry = new ToolRegistry({ journal }); + registry.register(fixture(async () => { effects++; return ok({ committed: true }); })); + const execution = await registry.executeDetailed("fixture_effect", { value: 1 }); + const reopened = new ExecutionJournal({ rootDir, streamId: "test", scopeDigest: "workspace-test" }); + const recovered = reopened.getExecution(execution.receipt.executionId); + assert.equal(recovered?.state, "settled"); + assert.equal(recovered?.settled?.modelContent, execution.modelContent); + assert.deepEqual(recovered?.settled?.result, execution.result); + assert.equal(effects, 1); + } finally { rmSync(rootDir, { recursive: true, force: true }); } +}); + +test("explicit stale capability is denied even with valid arguments and policy", async () => { + let effects = 0; + const registry = new ToolRegistry(); + const tool = fixture(async () => { effects++; return ok(effects); }); + const captured = capabilityDigest(tool); + registry.register(tool); + tool.description = "Changed after host discovery"; + assert.equal((await registry.executeDetailed(tool.name, { value: 1 }, { expectedCapabilityDigest: captured })).receipt.phase, "not_started"); + assert.equal(effects, 0); +}); + +test("implicit recursion fails instead of deadlocking; trusted nested calls keep actor/scope", async () => { + const registry = new ToolRegistry(); + registry.register(fixture(async () => ok("child"))); + registry.register(fixture(async (_params, context) => { + const rejected = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.equal(rejected.receipt.phase, "not_started"); + return registry.withChildExecution(async () => { + const attemptedWidening = await registry.executeDetailed("fixture_effect", { value: 1 }, { scope: { workspaceRoot: "/outside" } }); + assert.equal(attemptedWidening.receipt.phase, "not_started"); + const child = await registry.executeDetailed("fixture_effect", { value: 1 }); + assert.deepEqual(child.receipt.scope, context?.scope); + return ok(child.result.data); + }); + }, { name: "parent_effect" })); + assert.equal((await registry.executeDetailed("parent_effect", { value: 1 }, { scope: { workspaceRoot: "/fixture" } })).outcome, "succeeded"); +}); + +test("renewal lane progresses while an ordinary exclusive effect is in flight", async () => { + let finish!: () => void; + const pending = new Promise(resolve => { finish = resolve; }); + let started!: () => void; + const admitted = new Promise(resolve => { started = resolve; }); + const registry = new ToolRegistry(); + registry.register(fixture(async () => { started(); await pending; return ok("effect"); })); + const effect = registry.executeDetailed("fixture_effect", { value: 1 }); + await admitted; + const renew = registry.createRenewalRunner(fixture(async () => ok("renewed"), { name: "lease_renew" })); + assert.equal((await renew({ value: 1 })).result.data, "renewed"); + finish(); + assert.equal((await effect).outcome, "succeeded"); +}); + +test("pipeline uncertainty reaches result observers before the next queued effect",async()=>{ + let entered!:()=>void;let release!:()=>void;let uncertain=false;let laterEffects=0; + const entry=new Promise(resolve=>entered=resolve),barrier=new Promise(resolve=>release=resolve); + const registry=new ToolRegistry({ + policy:{authorize:()=>uncertain ? {decision:"deny",reasonCodes:["reconciliation_required"]}:{decision:"allow"}}, + logger:{error:()=>{throw new Error("Diagnostic sink failed after handler entry");}}, + journal:{record:()=>{},onSettled:execution=>{if(execution.outcome === "unknown")uncertain=true;}}, + }); + registry.register({name:"uncertain_first",description:"Enter then lose the execution pipeline",inputSchema:{type:"object",properties:{}},handler:async()=>{entered();await barrier;throw new Error("Effect response lost");}}); + registry.register({name:"queued_later",description:"Must not run after uncertainty",inputSchema:{type:"object",properties:{}},handler:async()=>{laterEffects++;return {success:true};}}); + const first=registry.executeDetailed("uncertain_first",{});await entry; + const next=registry.executeDetailed("queued_later",{});release(); + const [result,denied]=await Promise.all([first,next]); + assert.equal(result.outcome,"unknown");assert.equal(uncertain,true);assert.equal(denied.receipt.phase,"not_started");assert.equal(laterEffects,0); +}); diff --git a/test-client/run-framework-tests.ts b/test-client/run-framework-tests.ts index 5156149..9b696e5 100644 --- a/test-client/run-framework-tests.ts +++ b/test-client/run-framework-tests.ts @@ -42,6 +42,20 @@ import { formatValidationErrors, } from "../src/framework/validate.js"; import type { InputSchema } from "../src/framework/types.js"; +import type { ToolExecutionReceipt } from "../src/framework/execution-types.js"; +import type { RunToolEffects } from "../src/framework/runs.js"; + +function legacyEffectShape(effects: RunToolEffects | undefined) { + assert.ok(effects); + const ids = effects.receipts.map((receipt) => { + assert.equal(typeof receipt.executionReceiptId, "string"); + assert.match(receipt.executionReceiptId!, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.deepEqual(receipt.verificationRefs, []); + return receipt.executionReceiptId; + }); + assert.equal(new Set(ids).size, effects.receipts.length); + return { ...effects, receipts: effects.receipts.map(({ executionReceiptId, verificationRefs, ...receipt }) => receipt) }; +} // ── Resolve .data dir for cleanup ───────────────────────────────────────── const __filename = fileURLToPath(import.meta.url); @@ -1090,6 +1104,8 @@ test("openclaw bridge: execute() runs the tool and wraps the result", async () = assert.equal(result.isError, undefined); assert.equal(result.content.length, 1); assert.equal(result.content[0].type, "text"); + assert.equal(result.content[0].type, "text"); + if (result.content[0].type !== "text") throw new Error("Expected text content"); assert.match(result.content[0].text, /calculated/); assert.match(result.content[0].text, /"doubled": 42/); }); @@ -1109,6 +1125,7 @@ test("openclaw bridge: failed tool returns error block with isError", async () = const result = await tool.execute("call_1", {}); assert.equal(result.type, "error"); assert.equal(result.isError, true); + if (result.content[0].type !== "text") throw new Error("Expected text content"); assert.match(result.content[0].text, /boom/); }); @@ -1131,6 +1148,7 @@ test("openclaw bridge: validation errors flow through as error blocks", async () // Missing required param — registry.execute should refuse before the handler. const result = await tool.execute("call_1", {}); assert.equal(result.isError, true); + if (result.content[0].type !== "text") throw new Error("Expected text content"); assert.match(result.content[0].text, /field/i); }); @@ -1193,6 +1211,7 @@ test("openclaw bridge: custom formatResult is honored", async () => { }), }); const result = await tool.execute("c1", {}); + if (result.content[0].type !== "text") throw new Error("Expected text content"); assert.equal(result.content[0].text, 'OK: {"stamped":true}'); }); @@ -1207,6 +1226,7 @@ test("openclaw bridge: toolToOpenclaw works on a single tool", async () => { r.register(t); const oc = toolToOpenclaw(t, r); const result = await oc.execute("c1", {}); + if (result.content[0].type !== "text") throw new Error("Expected text content"); assert.match(result.content[0].text, /alone/); }); @@ -1315,6 +1335,8 @@ test("pi bridge: executes successes and throws failures", async () => { const [tool] = toPiTools(r); const result = await tool.execute("call-3", { text: "hello" }, undefined, undefined, {}); + assert.equal(result.content[0].type, "text"); + if (result.content[0].type !== "text") throw new Error("Expected text content"); assert.match(result.content[0].text, /hello/); assert.equal(result.details.success, true); await assert.rejects( @@ -1325,19 +1347,33 @@ test("pi bridge: executes successes and throws failures", async () => { test("pi bridge: honors an already-aborted signal", async () => { const r = new ToolRegistry(); + let effects = 0; r.register( defineTool({ name: "slow_tool", description: "a cancellable test tool", inputSchema: { type: "object", properties: {} }, - handler: async () => ok({ completed: true }), + handler: async () => { effects += 1; return ok({ completed: true }); }, }), ); const [tool] = toPiTools(r); await assert.rejects( tool.execute("call-5", {}, AbortSignal.abort(), undefined, {}), - /abort/i, + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /abort|cancelled/i); + const receipt = (error as Error & { executionReceipt: ToolExecutionReceipt }).executionReceipt; + assert.equal(receipt.callId, "call-5"); + assert.equal(receipt.phase, "not_started"); + assert.equal(receipt.admission, "rejected"); + assert.equal(receipt.executionOutcome, "failed"); + assert.equal(receipt.startedAt, undefined); + assert.equal(receipt.cancellationObserved, true); + assert.ok(receipt.reasonCodes.includes("cancelled_before_start")); + return true; + }, ); + assert.equal(effects, 0); }); test("hermes bridge: builds a least-privilege MCP config snapshot", () => { @@ -2443,6 +2479,7 @@ test("agent and workflow loops stop on the JSON-normalized unknown tool outcome" }); await assert.rejects(agent.run("run it", adapter, registry), (error: unknown) => { assert.ok(error instanceof RunExecutionError); + legacyEffectShape(error.toolEffects); assert.equal(error.toolEffects?.certainty, "unknown"); assert.equal( error.toolEffects?.receipts[0]?.result.executionOutcome, @@ -2458,7 +2495,7 @@ test("agent and workflow loops stop on the JSON-normalized unknown tool outcome" assert.match(error.message, /outcome is unknown/i); assert.equal(error.run.status, "failed"); assert.equal(error.run.stopReason, "error"); - assert.deepEqual(error.toolEffects, { + assert.deepEqual(legacyEffectShape(error.toolEffects), { state: "partial", certainty: "unknown", receipts: [{ @@ -2525,7 +2562,7 @@ test("workflow LLM step preserves ordered known receipts when a later adapter ca assert.match(error.message, /adapter failed after known effects/); assert.equal(error.run.status, "failed"); assert.equal(error.run.stopReason, "error"); - assert.deepEqual(error.toolEffects, { + assert.deepEqual(legacyEffectShape(error.toolEffects), { state: "partial", certainty: "known", receipts: [ @@ -2603,7 +2640,7 @@ test("workflow LLM step reports five-round exhaustion with every committed effec assert.equal(error.run.error, undefined); assert.equal(typeof error.run.completedAt, "string"); assert.equal(error.cause, undefined); - assert.deepEqual(error.toolEffects, { + assert.deepEqual(legacyEffectShape(error.toolEffects), { state: "partial", certainty: "known", receipts: Array.from({ length: 5 }, (_, index) => ({ diff --git a/test-client/run-grant-tests.ts b/test-client/run-grant-tests.ts new file mode 100644 index 0000000..a9816cf --- /dev/null +++ b/test-client/run-grant-tests.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { chmodSync, lstatSync, mkdtempSync, readdirSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LocalGrantStore, type IssueGrantOptions } from "../src/framework/work/grants.js"; +import type { WorkAuthorizationBinding } from "../src/framework/work/types.js"; + +const binding: WorkAuthorizationBinding = {backendId:"local",workItemId:"job",workspaceId:"workspace",contractDigest:"contract",revision:"1",planId:"plan",planVersion:1,planDigest:"plan-digest",scopeDigest:"scope"}; +function options(root:string, now:number, purpose: "execution"|"completion"="execution"):IssueGrantOptions { + return {purpose,expiresAt:now+1000,actions:["read","write","test"],roots:[root],include:["job.json"],exclude:[".git/**"],stepBindings:{correct:{toolName:"fixture_correct",capabilityDigest:"spec"}},acceptanceMethods:["fixture-readback"],limits:{maxAttempts:1,maxDurationMs:100,maxActions:1,maxOutputBytes:100}}; +} +function sandbox(){const root=mkdtempSync(join(tmpdir(),"of-grants-"));return {root,dispose:()=>rmSync(root,{recursive:true,force:true})};} + +test("persisted grants bind execution and completion to separate trusted decisions",async()=>{ + const box=sandbox();try{ + const store=new LocalGrantStore({rootDir:box.root,clock:()=>10}); + const execution=store.issue(binding,options(box.root,10)); + assert.equal(await store.resolve(binding,"completion"),undefined); + const completion=store.issue(binding,{...options(box.root,10,"completion"),executionGrantId:execution.id}); + assert.notEqual(completion.decisionRef,execution.decisionRef); + assert.equal((await new LocalGrantStore({rootDir:box.root,clock:()=>20}).resolve(binding,"execution"))?.id,execution.id); + assert.equal((await store.resolve(binding,"completion"))?.id,completion.id); + store.revoke(execution.id); + assert.equal(await store.resolve(binding,"execution"),undefined); + assert.equal(await store.resolve(binding,"completion"),undefined); + assert.equal(store.list()[0]?.revokedAt,10); + }finally{box.dispose();} +}); + +test("changed work identity, scope, plan or expiry refuses copied grants",async()=>{ + const box=sandbox();let now=10;try{ + const store=new LocalGrantStore({rootDir:box.root,clock:()=>now});store.issue(binding,options(box.root,now)); + for(const replacement of [{workItemId:"other"},{scopeDigest:"changed"},{planDigest:"changed"},{revision:"2"},{contractDigest:"changed"}]) assert.equal(await store.resolve({...binding,...replacement},"execution"),undefined); + now=1010;assert.equal(await store.resolve(binding,"execution"),undefined); + }finally{box.dispose();} +}); + +test("grant usage survives restart and enforces attempts, actions, output and duration",async()=>{ + const box=sandbox();let now=10;try{ + const store=new LocalGrantStore({rootDir:box.root,clock:()=>now});store.issue(binding,options(box.root,now)); + await store.reserveAttempt(binding,"a");await store.reserveAttempt(binding,"a"); + await assert.rejects(store.reserveAttempt(binding,"b"),/attempt limit/); + await store.reserveAction(binding,"a");await assert.rejects(store.reserveAction(binding,"a"),/action limit/); + await store.recordOutput(binding,"a",90);await assert.rejects(store.recordOutput(binding,"a",11),/output limit/); + const reopened=new LocalGrantStore({rootDir:box.root,clock:()=>now});await assert.rejects(reopened.reserveAction(binding,"a"),/action limit/); + now=110;await assert.rejects(reopened.recordOutput(binding,"a",1),/duration limit/); + }finally{box.dispose();} +}); + +test("grant state is private and refuses symlink/mode changes",()=>{ + const box=sandbox();try{ + const store=new LocalGrantStore({rootDir:box.root,clock:()=>10});store.issue(binding,options(box.root,10)); + assert.equal(lstatSync(box.root).mode & 0o777,0o700); + const log=join(box.root,readdirSync(box.root).find(value=>value.endsWith('.jsonl'))!); + assert.equal(lstatSync(log).mode & 0o777,0o600); + chmodSync(log,0o644);assert.throws(()=>store.list(),/0600/); + chmodSync(log,0o600); + const link=join(box.root,"link");symlinkSync(box.root,link);assert.throws(()=>new LocalGrantStore({rootDir:link}),/symlink/); + }finally{box.dispose();} +}); + + +test("stable execution output accounting is idempotent across restart and rejects conflicts",async()=>{ + const box=sandbox();try{ + const store=new LocalGrantStore({rootDir:box.root,clock:()=>10});store.issue(binding,options(box.root,10)); + await store.reserveAttempt(binding,"a");await store.recordOutput(binding,"a",90,"execution-1"); + const log=join(box.root,readdirSync(box.root).find(value=>value.endsWith('.jsonl'))!); + const size=lstatSync(log).size; + const reopened=new LocalGrantStore({rootDir:box.root,clock:()=>20}); + await reopened.recordOutput(binding,"a",90,"execution-1");assert.equal(lstatSync(log).size,size,"repeat must not append or double charge the acknowledged output"); + await assert.rejects(reopened.recordOutput(binding,"a",89,"execution-1"),/Conflicting output/); + await assert.rejects(reopened.recordOutput(binding,"a",1,""),/execution identity/); + await reopened.recordOutput(binding,"a",10,"execution-2"); + await assert.rejects(reopened.recordOutput(binding,"a",1,"execution-3"),/output limit/); + }finally{box.dispose();} +}); diff --git a/test-client/run-hermes-config-tests.ts b/test-client/run-hermes-config-tests.ts new file mode 100644 index 0000000..8ea8d13 --- /dev/null +++ b/test-client/run-hermes-config-tests.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createHermesMcpConfig } from "../src/framework/hermes.js"; +import { ToolRegistry } from "../src/framework/registry.js"; +test("Hermes helper preserves native trust, parked state, protocol and lifecycle settings", () => { + const lifecycle = { idle_timeout_seconds: 0, max_lifetime_seconds: 3600 }; + const value = createHermesMcpConfig(new ToolRegistry(), { + command: "openfunction", cwd: "/caller/profile", enabled: false, + protocol: "legacy", trust: "untrusted", lifecycle, + supportsParallelToolCalls: false, resources: false, prompts: false, + }); + assert.deepEqual(value.mcp_servers.openfunctions, { + command: "openfunction", cwd: "/caller/profile", enabled: false, + protocol: "legacy", trust: "untrusted", lifecycle, + supports_parallel_tool_calls: false, + tools: { include: [], resources: false, prompts: false }, + }); + lifecycle.max_lifetime_seconds = 1; + assert.equal(value.mcp_servers.openfunctions.lifecycle?.max_lifetime_seconds, 3600); +}); +test("Hermes helper rejects invalid lifecycle values before returning config", () => { + for (const value of [-1, Number.NaN, Infinity]) { + assert.throws(() => createHermesMcpConfig(new ToolRegistry(), { command: "openfunction", lifecycle: { idle_timeout_seconds: value } }), /non-negative/); + } + assert.throws(() => createHermesMcpConfig(new ToolRegistry(), { command: "openfunction", cwd: " " }), /cwd must not be empty/); +}); diff --git a/test-client/run-hermes-tests.mjs b/test-client/run-hermes-tests.mjs new file mode 100644 index 0000000..a4fbc3d --- /dev/null +++ b/test-client/run-hermes-tests.mjs @@ -0,0 +1,81 @@ +/** Real pinned Hermes integration. Missing explicit host inputs fail instead of skipping. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn, execFileSync } from "node:child_process"; +import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const options = {}; +for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i]; + if (!["--hermes-root", "--python", "--runtime-dir", "--tarball", "--mode", "--install-mode", "--out"].includes(key) || !process.argv[i + 1]) throw Error(`Invalid option ${key}`); + options[key.slice(2)] = process.argv[i + 1]; +} +for (const key of ["hermes-root", "python", "runtime-dir"]) { + if (!options[key] || !isAbsolute(options[key])) throw Error(`test:hermes requires absolute --${key}; provision Hermes with its PM first`); +} +const pin = "7cc3fb8cb55898747dd1dfa56fda3e16ebba028e"; +if (options.mode && !["smoke", "portable", "work"].includes(options.mode)) throw Error("Supported --mode values: smoke, portable, work"); +if (options["install-mode"] && !["offline", "online"].includes(options["install-mode"])) throw Error("--install-mode must be offline or online (dependency acquisition only)"); +if (options.out && !isAbsolute(options.out)) throw Error("--out must be an absolute evidence directory"); +function run(command, args, cwd = root, extra = {}) { + return execFileSync(command, args, { cwd, encoding: "utf8", env: { ...process.env, ...extra }, stdio: ["ignore", "pipe", "pipe"] }); +} +assert.equal(run("git", ["rev-parse", "HEAD"], options["hermes-root"]).trim(), pin, "Hermes must match the reviewed pin"); +const scratch = mkdtempSync(join(tmpdir(), "openfunction-hermes-")); +const consumer = join(scratch, "consumer"); +mkdirSync(consumer); +writeFileSync(join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" })); +const tarball = options.tarball ? resolve(options.tarball) : join(scratch, JSON.parse(run("npm", ["pack", "--json", "--pack-destination", scratch]))[0].filename); +run("npm", ["install", ...(options["install-mode"] === "online" ? [] : ["--offline"]), "--ignore-scripts", "--omit=optional", "--no-audit", "--no-fund", tarball], consumer); +assert.equal(existsSync(join(consumer, "node_modules/pg")), false, "Local Hermes proof must not require pg"); +assert.equal(existsSync(join(consumer, "node_modules/@siftable/mcp-server")), false, "Local Hermes proof must not require Siftable"); +const binary = join(consumer, "node_modules/.bin/openfunction"); +const effect = join(scratch, "effects.jsonl"); +const fixture = join(consumer, "tools.mjs"); +writeFileSync(fixture, `import {ToolRegistry,defineTool,ok} from 'openfunction/framework/core'; +import {appendFileSync,readFileSync,existsSync} from 'node:fs'; +export const registry=new ToolRegistry(); +registry.register(defineTool({name:'smoke_read',description:'Read isolated fixture effect count',inputSchema:{type:'object',properties:{}},annotations:{readOnlyHint:true},handler:async()=>ok({count:existsSync(${JSON.stringify(effect)})?readFileSync(${JSON.stringify(effect)},'utf8').trim().split('\\n').length:0})})); +registry.register(defineTool({name:'smoke_write',description:'Append one isolated fixture effect',inputSchema:{type:'object',properties:{}},annotations:{readOnlyHint:false},handler:async()=>{appendFileSync(${JSON.stringify(effect)},JSON.stringify({effect:'approved'})+'\\n');return ok({written:true});}}));`); +const home = join(scratch, "profile-a"); +mkdirSync(home); +const settings = { + model: { context_length: 65536 }, // Declared local fixture window; the pinned host requires >=64K. + mcp_servers: { openfunctions: { command: binary, args: ["serve", "--module", fixture], cwd: consumer, protocol: "legacy", trust: "untrusted", timeout: 30, connect_timeout: 30, supports_parallel_tool_calls: false, tools: { include: ["smoke_read", "smoke_write"], resources: false, prompts: false } } }, +}; +const configPath = join(home, "config.yaml"); +writeFileSync(configPath, JSON.stringify(settings)); // JSON is a YAML subset. +const details = { pin, scratch, binary, fixture, effect, plugin: join(root, "plugins/hermes-openfunctions"), python: options.python, mode: options.mode ?? "smoke", tarball, tarballSha256: createHash("sha256").update(readFileSync(tarball)).digest("hex"), nodeMcpSdk: JSON.parse(readFileSync(join(consumer, "node_modules/@modelcontextprotocol/sdk/package.json"), "utf8")).version, optionalBackendsInstalled: false }; +if (details.mode !== "smoke") { + const gitFixture = join(scratch, "plugin-fixture"); + mkdirSync(gitFixture); + cpSync(details.plugin, join(gitFixture, "portable"), { recursive: true }); + run("git", ["init", "--quiet"], gitFixture); + run("git", ["add", "portable"], gitFixture); + run("git", ["-c", "user.name=OpenFunction test fixture", "-c", "user.email=fixture@localhost", "commit", "--quiet", "-m", "Hermes isolated portable fixture"], gitFixture); + details.pluginSource = `file://${gitFixture}#portable`; + details.pluginRef = run("git", ["rev-parse", "HEAD"], gitFixture).trim(); +} +writeFileSync(join(scratch, "inputs.json"), JSON.stringify(details)); +const env = { ...process.env, HERMES_HOME: home, HERMES_RUNTIME_DIR: options["runtime-dir"], PATH: `${join(consumer, "node_modules/.bin")}:${process.env.PATH}`, OPENAI_API_KEY: "local-fixture-only", OPENROUTER_API_KEY: "", ANTHROPIC_API_KEY: "", DATABASE_URL: "" }; +const stdout = openSync(join(scratch, "host.stdout.log"), "w"); +const stderr = openSync(join(scratch, "host.stderr.log"), "w"); +console.log(`Running pinned Hermes in ${scratch}`); +const child = spawn(join(options["hermes-root"], "scripts/run-in-hermes-env"), [options.python, join(root, "test-client/fixtures/hermes-host-proof.py"), join(scratch, "inputs.json")], { cwd: options["hermes-root"], env, stdio: ["ignore", stdout, stderr], timeout: 600_000 }); +const result = await new Promise((accept, reject) => { child.once("error", reject); child.once("exit", (status, signal) => accept({ status, signal })); }); +closeSync(stdout); closeSync(stderr); +if (options.out) { + mkdirSync(options.out, { recursive: true, mode: 0o700 }); + for (const name of ["evidence.json", "inputs.json", "host.stdout.log", "host.stderr.log"]) { + if (existsSync(join(scratch, name))) copyFileSync(join(scratch, name), join(options.out, name)); + } +} +if (result.status !== 0) throw Error(`Real Hermes proof failed (${result.status ?? result.signal}); logs: ${scratch}\n${readFileSync(join(scratch, "host.stderr.log"), "utf8").slice(-4000)}`); +const evidence = JSON.parse(readFileSync(join(scratch, "evidence.json"), "utf8")); +assert.equal(evidence.hermesCommit, pin); +assert.equal(evidence.approvedEffects, 1); +assert.equal(evidence.refusalEffects, 0); +console.log(`Real Hermes ${details.mode} proof passed; evidence: ${join(scratch, "evidence.json")}`); diff --git a/test-client/run-host-plugin-tests.mjs b/test-client/run-host-plugin-tests.mjs new file mode 100644 index 0000000..6955372 --- /dev/null +++ b/test-client/run-host-plugin-tests.mjs @@ -0,0 +1,99 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, chmodSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createLocalFixtureBindings } from '../dist/src/framework/work/index.js'; +import mixed from '../plugins/openclaw-execufunction/dist/index.js'; +import staticPlugin from '../plugins/openclaw-openfunctions/dist/index.js'; + +function load(plugin, config = {}) { + const tools = [], services = []; + plugin.register({ config, registerTool(tool) { tools.push(tool); }, registerService(service) { services.push(service); } }); + return { tools, services }; +} + +test('actual OpenClaw static SDK factory loads and preserves known success / failure calls', async () => { + const { tools } = load(staticPlugin); + assert.deepEqual(tools.map((tool) => tool.name).sort(), ['of_calculate', 'of_convert_units']); + const tool = tools.find((tool) => tool.name === 'of_calculate'); + const result = await tool.execute('sdk-success', { a: 8, b: 2, operator: 'divide' }); + assert.match(JSON.stringify(result), /4/); + await assert.rejects(tool.execute('sdk-failure', { a: 8, b: 0, operator: 'divide' }), /division by zero/); + await assert.rejects(tool.execute('sdk-validation', { a: 8, b: 0 }), /operator/); +}); + +test('mixed OpenClaw SDK registration is credential-free, excludes unsupported hosted sessions, and exposes local work separately', async () => { + const tokenNames = ['SIFT_TOKEN', 'SIFT_PAT', 'EXF_TOKEN', 'EXF_PAT']; + const saved = Object.fromEntries(tokenNames.map((name) => [name, process.env[name]])); + for (const name of tokenNames) delete process.env[name]; + try { + const { tools } = load(mixed); + assert.equal(new Set(tools.map((tool) => tool.name)).size, tools.length); + assert.ok(tools.some((tool) => tool.name === 'of_work_start')); + assert.ok(tools.every((tool) => !tool.name.startsWith('work_session_'))); + const tool = tools.find((tool) => tool.name === 'of_work_start'); + const result = await tool.execute('no-profile', { workItemId: 'job' }); + assert.equal(result.isError, true); + assert.match(JSON.stringify(result), /workConfigPath|OPENFUNCTION_WORK_CONFIG/); + } finally { + for (const name of tokenNames) { + if (saved[name] === undefined) delete process.env[name]; else process.env[name] = saved[name]; + } + } +}); + +for (const staleSubject of [false, true]) test(staleSubject + ? 'actual OpenClaw native completion refuses a subject changed after verification' + : 'actual OpenClaw native calls complete a bounded local job with CLI-issued grants and fresh proof', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'openfunction-openclaw-')); chmodSync(root, 0o700); + let activeServices = []; + t.after(async () => { for (const service of activeServices) await service.stop?.(); rmSync(root, { recursive: true, force: true }); }); + const fixture = join(root, 'fixture.json'), verifier = join(root, 'verify.mjs'), configPath = join(root, 'profile.json'), previewPath = join(root, 'preview.json'); + writeFileSync(fixture, '{"status":"needs_correction","value":0}\n', { mode: 0o600 }); + writeFileSync(verifier, 'import fs from "node:fs";const x=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));if(x.value!==1||x.status!=="corrected")process.exit(1);console.log("verified");\n', { mode: 0o600 }); + const binding = createLocalFixtureBindings({ workspaceRoot: root, fixturePath: 'fixture.json', verifierPath: 'verify.mjs' }); + writeFileSync(configPath, JSON.stringify({ + schemaVersion: 1, workspaceRoot: root, dataDir: join(root, 'data'), grantDir: join(root, 'grants'), + backend: { kind: 'local', backendId: 'openclaw-test', workspaceId: 'offline-workspace', workItems: [{ workItemId: 'job', contract: binding.contract, plan: { id: 'plan', version: 1, state: 'active', steps: [binding.verificationStep] } }] }, + stepCatalog: { kind: 'fixture', fixturePath: 'fixture.json', verifierPath: 'verify.mjs' }, + }), { mode: 0o600 }); + const cli = resolve('dist/src/cli.js'); + execFileSync(process.execPath, [cli, 'work', 'prepare', '--config', configPath, '--work-item', 'job', '--out', previewPath]); + const authorization = execFileSync(process.execPath, [cli, 'work', 'authorize', '--config', configPath, '--preview', previewPath], { input: 'AUTHORIZE\nCOMPLETE\n', encoding: 'utf8' }); + assert.match(authorization, /"automaticCompletion":\s*true/); + const { tools, services } = load(mixed, { plugins: { entries: { execufunction: { config: { workConfigPath: configPath } } } } }); + activeServices = services; + let callCount = 0; + const call = async (name, params = {}) => { + const result = await tools.find((tool) => tool.name === name).execute(`native-${++callCount}-${name}`, params); + assert.notEqual(result.isError, true, JSON.stringify(result)); + assert.ok(result.details?.executionReceipt.executionId); + return result.details.result.data; + }; + await call('of_work_start', { workItemId: 'job' }); + const inspected = await call('of_work_step', { stepKey: 'inspect', params: {} }); + assert.equal(inspected.result.success, true); + await call('of_work_step', { stepKey: 'correct', params: { expectedDigest: inspected.result.data.expectedDigest } }); + const evidence = await call('of_work_verify', { stepId: 'fixture-check' }); + assert.equal(evidence.outcome, 'passed'); + assert.equal(evidence.readbackPassed, true); + if (staleSubject) writeFileSync(fixture, '{"status":"needs_correction","value":0}\n'); + assert.equal((await call('of_work_finish')).state, staleSubject ? 'needs_review' : 'completed'); + assert.equal(JSON.parse(readFileSync(fixture, 'utf8')).value, staleSubject ? 0 : 1); +}); + +test('actual pinned Pi loader accepts the packaged extension and executes its native contract', async (t) => { + const { discoverAndLoadExtensions } = await import('../plugins/pi-openfunctions/node_modules/@earendil-works/pi-coding-agent/dist/index.js'); + const root = mkdtempSync(join(tmpdir(), 'openfunction-pi-host-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const loaded = await discoverAndLoadExtensions([resolve('plugins/pi-openfunctions/src/index.ts')], root, join(root, 'agent')); + assert.deepEqual(loaded.errors, []); + assert.equal(loaded.extensions.length, 1); + const tool = loaded.extensions[0].tools.get('of_add').definition; + const result = await tool.execute('pi-native-success', { a: 2, b: 3 }, undefined, undefined, {}); + assert.deepEqual(result.details.data, { result: 5 }); + assert.equal(result.details.executionReceipt.callId, 'pi-native-success'); + await assert.rejects(tool.execute('pi-native-validation', { a: 2 }, undefined, undefined, {}), /b/); +}); diff --git a/test-client/run-legible-execution-tests.ts b/test-client/run-legible-execution-tests.ts index 1b72e88..40ce19a 100644 --- a/test-client/run-legible-execution-tests.ts +++ b/test-client/run-legible-execution-tests.ts @@ -18,8 +18,17 @@ import { evaluateAssurance, RunExecutionError, type VerificationAttempt, + type RunToolEffectReceipt, } from "../src/framework/runs.js"; +function receiptWithoutRefs(receipt: RunToolEffectReceipt | undefined) { + if (!receipt) return receipt; + const { executionReceiptId, verificationRefs, ...legacy } = receipt; + if (executionReceiptId !== undefined) assert.match(executionReceiptId, /^[a-f0-9-]{36}$/); + if (verificationRefs !== undefined) assert.ok(Array.isArray(verificationRefs)); + return legacy; +} + function adapter(responses: Array): AIAdapter { let index = 0; return { @@ -177,7 +186,7 @@ test("agent results expose completed and limited runs without false claims", asy assert.equal(limited.run.status, "limit_reached"); assert.equal(limited.run.toolEffects?.state, "partial"); assert.equal(limited.run.toolEffects?.certainty, "known"); - assert.deepEqual(limited.run.toolEffects?.receipts, [{ + assert.deepEqual(limited.run.toolEffects?.receipts?.map(receiptWithoutRefs), [{ name: "record_limit_effect", args: {}, result: { @@ -237,7 +246,7 @@ test("agent failures retain an unknown tool-effect receipt and verification requ error.toolEffects?.verificationRequired, "verify tool side effects before deciding whether any retry is safe", ); - assert.deepEqual(error.toolEffects?.receipts, [{ + assert.deepEqual(error.toolEffects?.receipts?.map(receiptWithoutRefs), [{ name: "uncertain_publish", args: { destination: "release" }, result: { @@ -311,7 +320,7 @@ test("parallel agent calls retain later receipts when an earlier result is not s error.toolEffects?.receipts[0].result.error ?? "", /Tool execution outcome is unknown:.*BigInt/i, ); - assert.deepEqual(error.toolEffects?.receipts[1], { + assert.deepEqual(receiptWithoutRefs(error.toolEffects?.receipts[1]), { name: "record_later_effect", args: {}, result: { @@ -386,6 +395,19 @@ test("parallel agent calls normalize executor rejections without losing later ef }; }, })); + // Deliberately inject an executor rejection into the projected facade. + // Registration snapshots prevent mutation of the original schema from + // changing execution, so schema mutation is no longer a valid fixture. + const project = registry.project.bind(registry); + registry.project = (...selection) => { + const projected = project(...selection); + const execute = projected.executeDetailed.bind(projected); + projected.executeDetailed = async (name, ...args) => { + if (name === "reject_executor_pipeline") throw new Error("executor pipeline rejected"); + return execute(name, ...args); + }; + return projected; + }; const worker = defineAgent({ name: "executor_rejection_worker", role: "Executor rejection worker", @@ -397,17 +419,6 @@ test("parallel agent calls normalize executor rejections without losing later ef model: "test-model", async chat() { adapterCalls += 1; - // The run manifest has already snapshotted the valid schema. Mutating - // this local test tool now makes validation reject outside the - // registry's handler try/catch, exercising defineAgent's settlement. - rejectedTool.inputSchema.properties = new Proxy( - rejectedTool.inputSchema.properties, - { - get() { - throw new Error("executor pipeline rejected"); - }, - }, - ); return { toolCalls: [ { @@ -431,7 +442,7 @@ test("parallel agent calls normalize executor rejections without losing later ef assert.ok(error instanceof RunExecutionError); assert.equal(error.toolEffects?.certainty, "unknown"); assert.equal(error.toolEffects?.receipts.length, 2); - assert.deepEqual(error.toolEffects?.receipts[0], { + assert.deepEqual(receiptWithoutRefs(error.toolEffects?.receipts[0]), { name: "reject_executor_pipeline", args: { trigger: true }, result: { @@ -440,7 +451,7 @@ test("parallel agent calls normalize executor rejections without losing later ef executionOutcome: "unknown", }, }); - assert.deepEqual(error.toolEffects?.receipts[1], { + assert.deepEqual(receiptWithoutRefs(error.toolEffects?.receipts[1]), { name: "record_after_rejection", args: {}, result: { @@ -488,7 +499,7 @@ test("agent adapter failures retain receipts for known earlier effects", async ( assert.equal(error.run.status, "failed"); assert.equal(error.toolEffects?.certainty, "known"); assert.equal(error.toolEffects?.verificationRequired, undefined); - assert.deepEqual(error.toolEffects?.receipts, [{ + assert.deepEqual(error.toolEffects?.receipts?.map(receiptWithoutRefs), [{ name: "record_checkpoint", args: {}, result: { diff --git a/test-client/run-mcp-provider-tests.ts b/test-client/run-mcp-provider-tests.ts new file mode 100644 index 0000000..10838c2 --- /dev/null +++ b/test-client/run-mcp-provider-tests.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env tsx +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { CallToolRequestSchema, ListToolsRequestSchema, type Tool } from "@modelcontextprotocol/sdk/types.js"; +import { createMcpProvider, validateMcpEndpoint } from "../src/providers/mcp/index.js"; + +const schema = { type: "object" as const, properties: {} }; +const catalog: Tool[] = [ + { name: "read_value", description: "Read a structured value", inputSchema: schema, annotations: { readOnlyHint: true }, outputSchema: { type: "object", properties: { value: { type: "integer" } }, required: ["value"] } }, + { name: "write_value", description: "Write a structured value", inputSchema: schema, annotations: { readOnlyHint: false } }, +]; + +function fixture() { + const server = new Server({ name: "provider-fixture", version: "1" }, { capabilities: { tools: { listChanged: true } } }); + let calls = 0; + let pages = 0; + let abortObserved = false; + server.setRequestHandler(ListToolsRequestSchema, async (request) => { + pages++; + return request.params?.cursor === "page-2" ? { tools: [catalog[1]] } : { tools: [catalog[0]], nextCursor: "page-2" }; + }); + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + calls++; + if (request.params.arguments?.wait) { + await new Promise((resolve) => extra.signal.addEventListener("abort", () => { abortObserved = true; resolve(); }, { once: true })); + return { content: [{ type: "text", text: "cancel observed" }], isError: true }; + } + if (request.params.name === "write_value") { + return { content: [{ type: "text", text: "Error: response interrupted after effect" }], isError: true }; + } + return { content: [{ type: "text", text: "Error: is ordinary readable content here" }], structuredContent: { value: 42 }, _meta: { schemaId: "fixture.v1" } }; + }); + return { + server, get calls() { return calls; }, get pages() { return pages; }, get abortObserved() { return abortObserved; }, + async provider() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + return createMcpProvider({ id: "fixture", createTransport: () => clientTransport }).connect(); + }, + }; +} + +if (process.argv.includes("--provider-fixture")) { + await fixture().server.connect(new StdioServerTransport()); +} else { + test("generic MCP uses paginated frozen catalogs and preserves rich structured results", async () => { + const upstream = fixture(); + const provider = await upstream.provider(); + try { + assert.equal(upstream.pages, 2); + assert.equal(provider.catalog.tools.length, 2); + assert.ok(Object.isFrozen(provider.catalog.tools[0].inputSchema)); + const tools = provider.createTools(); + assert.deepEqual(tools[0].annotations, catalog[0].annotations); + assert.deepEqual(tools[0].outputSchema, catalog[0].outputSchema); + const result = await tools[0].handler({}); + assert.equal(result.success, true, "human text is not an error oracle"); + assert.deepEqual(result.structuredContent, { value: 42 }); + assert.equal(result._meta?.schemaId, "fixture.v1"); + const failed = await tools[1].handler({}); + assert.equal(failed.executionOutcome, "unknown"); + assert.equal(upstream.calls, 2, "there is no transparent write retry"); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } + }); + + test("catalog change invalidates projections until explicit safe-boundary refresh", async () => { + const upstream = fixture(); + const provider = await upstream.provider(); + try { + const old = provider.createTools()[0]; + await upstream.server.notification({ method: "notifications/tools/list_changed" }); + assert.equal(provider.catalogStale, true); + assert.equal((await old.handler({})).success, false); + assert.equal(upstream.calls, 0); + await provider.refreshCatalog(); + assert.equal((await old.handler({})).success, false, "old projection must stay invalid after refresh"); + assert.equal((await provider.createTools()[0].handler({})).success, true); + assert.equal(upstream.calls, 1); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } + }); + + test("cancellation reaches upstream and retains unknown effect without retry", async () => { + const upstream = fixture(); + const provider = await upstream.provider(); + const controller = new AbortController(); + try { + const tools = provider.createTools(); + const pending = tools[1].handler({ wait: true }, { signal: controller.signal }); + await new Promise((resolve) => setImmediate(resolve)); + await assert.rejects(provider.refreshCatalog(), /no active calls/); + controller.abort(); + const result = await pending; + assert.equal(result.executionOutcome, "unknown"); + assert.equal(upstream.calls, 1); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(upstream.abortObserved, true); + const before = await tools[1].handler({}, { signal: controller.signal }); + assert.equal(before.executionOutcome, "failed"); + assert.equal(upstream.calls, 1); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } + }); + + test("upstream elicitation is rejected with host guidance and no fabricated approval", async () => { + const upstream = fixture(); + const provider = await upstream.provider(); + try { + assert.equal(upstream.server.getClientCapabilities()?.elicitation, undefined); + assert.equal(upstream.server.getClientCapabilities()?.sampling, undefined); + const transport = upstream.server.transport!; + const original = transport.onmessage; + const reply = new Promise((resolve) => { + transport.onmessage = (message, extra) => { + if ("id" in message && message.id === "host-input" && "error" in message) resolve(message); + else original?.(message, extra); + }; + }); + // A noncompliant upstream may request input despite absent capabilities. + await transport.send({ jsonrpc: "2.0", id: "host-input", method: "elicitation/create", params: { + message: "Approve the write", requestedSchema: { type: "object", properties: { approved: { type: "boolean" } } }, + } }); + const result = await reply; + assert.match(result.error.message, /trusted host callback/); + assert.equal(result.result, undefined); + assert.equal(upstream.calls, 0); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } + }); + + test("disconnect only closes the owning MCP connection", async () => { + const first = fixture(); + const second = fixture(); + const a = await first.provider(); + const b = await second.provider(); + try { + await a.disconnect(); + assert.equal((await a.createTools()[0].handler({})).executionOutcome, "failed"); + assert.equal((await b.createTools()[0].handler({})).success, true); + } finally { + await b.disconnect(); + await first.server.close(); + await second.server.close(); + } + }); + + test("generic stdio transport calls the real SDK child process", async () => { + const provider = await createMcpProvider({ + id: "stdio", transport: { kind: "stdio", command: process.execPath, + args: [...process.execArgv, fileURLToPath(import.meta.url), "--provider-fixture"] }, + }).connect(); + try { + assert.equal(provider.catalog.tools.length, 2); + const result = await provider.createTools()[0].handler({}); + assert.deepEqual(result.structuredContent, { value: 42 }); + } finally { + await provider.disconnect(); + } + }); + + test("HTTP credentials come from host callbacks and never cross an origin redirect", async () => { + const seen: { url: string; auth: string | null }[] = []; + const provider = createMcpProvider({ + id: "redirect", transport: { kind: "streamable_http", url: "https://trusted.example/mcp", resolveHeaders: () => ({ Authorization: "Bearer fixture-host-credential" }) }, + fetch: async (url, init) => { + seen.push({ url: String(url), auth: new Headers(init?.headers).get("authorization") }); + return new Response(null, { status: 307, headers: { location: "https://other.example/mcp" } }); + }, + }); + await assert.rejects(provider.connect(), /cross-origin redirect/); + assert.deepEqual(seen, [{ url: "https://trusted.example/mcp", auth: "Bearer fixture-host-credential" }]); + assert.equal(validateMcpEndpoint("http://127.0.0.1:4444/mcp").protocol, "http:"); + assert.equal(validateMcpEndpoint("http://[::1]/mcp").protocol, "http:"); + assert.throws(() => validateMcpEndpoint("http://remote.example/mcp"), /HTTPS/); + assert.throws(() => validateMcpEndpoint("https://user:password@example.com/mcp"), /trusted auth callback/); + }); + + test("StreamableHTTP transports preserve calls without forwarding claimed credential arguments", async () => { + const calls: string[] = []; + const headers: string[] = []; + const provider = await createMcpProvider({ + id: "http", transport: { kind: "streamable_http", url: "http://localhost:7777/mcp", resolveHeaders: () => ({ authorization: "Bearer host-value" }) }, + fetch: async (_url, init) => { + headers.push(new Headers(init?.headers).get("authorization") ?? ""); + if (init?.method === "GET") return new Response(null, { status: 405 }); + const message = JSON.parse(String(init?.body)); + if (message.id === undefined) return new Response(null, { status: 202 }); + calls.push(message.method); + const result = message.method === "initialize" + ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "http-fixture", version: "1" } } + : message.method === "tools/list" ? { tools: catalog } + : { content: [{ type: "text", text: "HTTP result" }], structuredContent: { value: 42 } }; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }), { headers: { "content-type": "application/json" } }); + }, + }).connect(); + try { + const result = await provider.createTools()[0].handler({ authorization: "model-forged-value" }); + assert.equal(result.success, true); + assert.ok(headers.every((header) => header === "Bearer host-value")); + assert.equal(calls.filter((method) => method === "tools/call").length, 1); + } finally { + await provider.disconnect(); + } + }); +} diff --git a/test-client/run-mcp-tests.ts b/test-client/run-mcp-tests.ts new file mode 100644 index 0000000..98a8efa --- /dev/null +++ b/test-client/run-mcp-tests.ts @@ -0,0 +1,252 @@ +#!/usr/bin/env tsx +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { createMcpServer, startServer } from "../src/framework/server.js"; +import { TOOLS } from "@siftable/mcp-server"; +import { createSiftableSdkTools } from "../src/providers/execufunction/sdk-tools.js"; +import type { ExfClient } from "../src/providers/execufunction/client.js"; + +const emptySchema = { type: "object" as const, properties: {} }; +function fixtureRegistry() { + const registry = new ToolRegistry(); + registry.register({ + name: "empty_result", description: "Return undefined legacy data", inputSchema: emptySchema, + annotations: { readOnlyHint: true }, + handler: async () => ({ success: true }), + }); + registry.register({ + name: "rich_result", description: "Return structured and resource content", inputSchema: emptySchema, + annotations: { readOnlyHint: true, destructiveHint: false }, + outputSchema: { type: "object", properties: { value: { type: "number" } }, required: ["value"] }, + handler: async () => ({ + success: true, + structuredContent: { value: 42 }, + content: [ + { type: "text", text: "The answer" }, + { type: "image", data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a6N8AAAAASUVORK5CYII=", mimeType: "image/png" }, + { type: "resource_link", name: "Receipt", uri: "file:///receipt.json" }, + { type: "resource", resource: { uri: "file:///note.txt", text: "evidence", mimeType: "text/plain" } }, + { type: "resource", resource: { uri: "file:///receipt.bin", blob: "YmxvYg==", mimeType: "application/octet-stream" } }, + ], + _meta: { upstream: "kept" }, + }), + }); + registry.register({ + name: "uncertain_write", description: "A write with an uncertain response", inputSchema: emptySchema, + annotations: { readOnlyHint: false }, + handler: async () => ({ success: false, error: "connection interrupted", executionOutcome: "unknown" }), + }); + registry.register({ + name: "throws_after_effect", description: "Simulate throw after the effect", inputSchema: emptySchema, + handler: async () => { throw new Error("response unavailable"); }, + }); + return registry; +} + +if (process.argv.includes("--mcp-fixture")) { + await startServer(fixtureRegistry(), { name: "protocol-fixture" }); +} else { + test("stdio emits only JSON-RPC and preserves discovery, rich content, and uncertainty", async () => { + const child = spawn(process.execPath, [ + ...process.execArgv, fileURLToPath(import.meta.url), "--mcp-fixture", + ], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let buffer = ""; + let protocolError: Error | undefined; + let nextId = 1; + const pending = new Map(); + child.stderr.on("data", (data) => { stderr += String(data); }); + child.stdout.on("data", (data) => { + stdout += String(data); + buffer += String(data); + for (;;) { + const newline = buffer.indexOf("\n"); + if (newline < 0) break; + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + try { + const message = JSON.parse(line); + assert.equal(message.jsonrpc, "2.0"); + const waiter = pending.get(message.id); + if (waiter) { + pending.delete(message.id); + if (message.error) waiter.reject(new Error(JSON.stringify(message.error))); + else waiter.resolve(message.result); + } + } catch (error) { + protocolError = error instanceof Error ? error : new Error(String(error)); + for (const waiter of pending.values()) waiter.reject(protocolError); + pending.clear(); + } + } + }); + const request = (method: string, params: unknown) => new Promise((resolve, reject) => { + const id = nextId++; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`MCP timeout: ${method}; stderr=${stderr}`)); }, 10000); + pending.set(id, { + resolve(value) { clearTimeout(timer); resolve(value); }, + reject(error) { clearTimeout(timer); reject(error); }, + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); + try { + await request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "untrusted-client-identity", version: "1" } }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`); + const listing = await request("tools/list", {}); + const richTool = listing.tools.find((tool: any) => tool.name === "rich_result"); + assert.equal(richTool.annotations.readOnlyHint, true); + assert.equal(richTool.outputSchema.type, "object"); + const empty = await request("tools/call", { name: "empty_result", arguments: {} }); + assert.equal(typeof empty.content[0].text, "string"); + const rich = await request("tools/call", { name: "rich_result", arguments: {} }); + assert.deepEqual(rich.structuredContent, { value: 42 }); + assert.deepEqual(rich.content.map((item: any) => item.type), ["text", "image", "resource_link", "resource", "resource"]); + assert.equal(rich.content[3].resource.text, "evidence"); + assert.equal(rich.content[4].resource.blob, "YmxvYg=="); + assert.equal(rich._meta.upstream, "kept"); + for (const name of ["uncertain_write", "throws_after_effect"]) { + const result = await request("tools/call", { name, arguments: {} }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /unknown/i); + assert.equal(result._meta["openfunction/execution"].executionOutcome, "unknown"); + } + const unknown = await request("tools/call", { name: "missing_tool", arguments: {} }); + assert.equal(unknown.isError, true); + assert.equal(unknown._meta["openfunction/execution"].phase, "not_started"); + assert.equal(protocolError, undefined); + assert.ok(stdout.split("\n").filter(Boolean).every((line) => JSON.parse(line).jsonrpc === "2.0")); + } finally { + child.kill(); + for (const waiter of pending.values()) waiter.reject(new Error("Fixture closed")); + } + }); + + test("MCP lifecycle binds trusted context and signals without trusting arguments", async () => { + const registry = new ToolRegistry(); + let received: any; + registry.register({ + name: "inspect_context", description: "Return context supplied by the host", + inputSchema: { type: "object", properties: { actor: { type: "string" } } }, + handler: async (_params, context) => { + received = context; + return { success: true, data: { actorId: context?.actor?.id, correlationId: context?.correlationId } }; + }, + }); + const handle = createMcpServer(registry, { + name: "trusted-host", + resolveContext: () => ({ actor: { kind: "local_user", id: "host-bound-user" } }), + }); + const client = new Client({ name: "client-asserted-identity", version: "1" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + try { + await handle.connect(serverTransport); + await client.connect(clientTransport); + await client.callTool({ name: "inspect_context", arguments: { actor: "forged-admin" } }); + assert.equal(received.actor.id, "host-bound-user"); + assert.equal(typeof received.correlationId, "string"); + assert.ok(received.signal instanceof AbortSignal); + await assert.rejects(handle.connect(serverTransport), /already connected/); + } finally { + await client.close(); + await handle.close(); + await handle.close(); + } + await assert.rejects(handle.connect(serverTransport), /closed/); + + const unbound = createMcpServer(registry, { name: "unbound" }); + const unboundClient = new Client({ name: "forged-admin", version: "1" }); + const [left, right] = InMemoryTransport.createLinkedPair(); + try { + await unbound.connect(right); + await unboundClient.connect(left); + await unboundClient.callTool({ name: "inspect_context", arguments: { actor: "forged-admin" } }); + assert.equal(received.actor, undefined); + } finally { + await unboundClient.close(); + await unbound.close(); + } + }); + + test("MCP request cancellation reaches the cooperative handler", async () => { + const registry = new ToolRegistry(); + let markStarted!: () => void; + let markAborted!: () => void; + const started = new Promise((resolve) => { markStarted = resolve; }); + const aborted = new Promise((resolve) => { markAborted = resolve; }); + registry.register({ + name: "wait_for_cancel", description: "Wait for client cancellation", inputSchema: emptySchema, + handler: async (_params, context) => { + assert.ok(context?.signal); + markStarted(); + await new Promise((resolve) => { + context.signal!.addEventListener("abort", () => { markAborted(); resolve(); }, { once: true }); + }); + return { success: false, error: "cancelled during execution", executionOutcome: "unknown" }; + }, + }); + const handle = createMcpServer(registry, { name: "cancellation" }); + const client = new Client({ name: "cancellation-client", version: "1" }); + const [left, right] = InMemoryTransport.createLinkedPair(); + const controller = new AbortController(); + try { + await handle.connect(right); + await client.connect(left); + const call = client.callTool({ name: "wait_for_cancel", arguments: {} }, undefined, { signal: controller.signal }); + const rejected = assert.rejects(call); + await started; + controller.abort(); + await rejected; + await Promise.race([ + aborted, + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("Cancellation did not reach handler")), 1000); + timer.unref(); + }), + ]); + } finally { + controller.abort(); + await client.close(); + await handle.close(); + } + }); + + test("Siftable preserves SDK schemas, structured schema IDs, idempotency, and uncertain errors", async () => { + const fake = { + listProjects: async () => ({ statusCode: 200, data: { projects: [] } }), + updateTask: async () => ({ + statusCode: 200, data: { task: { title: "updated", status: "open" } }, + idempotency: { disposition: "replayed", payloadDigest: "abc" }, + }), + createTask: async () => ({ statusCode: 503, error: "response interrupted" }), + }; + const tools = createSiftableSdkTools({ raw: () => fake } as unknown as ExfClient); + const list = tools.find((tool) => tool.name === "project_list")!; + const source = TOOLS.find((tool) => tool.name === "project_list")! as unknown as Tool; + assert.equal(list.annotations?.readOnlyHint, true); + assert.equal(list.outputSchema?.type, "object"); + assert.deepEqual(list.annotations, source.annotations); + assert.deepEqual(list._meta?.["siftable/upstreamOutputSchema"], source.outputSchema); + assert.ok(list.outputSchema?.properties.projects); + assert.notEqual(list.annotations, source.annotations); + const registry = new ToolRegistry(); + registry.registerAll(tools); + const result = (await registry.executeDetailed("project_list", {})).result; + assert.equal(result.success, true); + assert.deepEqual(result.structuredContent, result.data); + assert.equal((result._meta?.["siftable/execution"] as any).structuredResult.schemaId, "sift.project.list.v2"); + const update = await tools.find((tool) => tool.name === "task_update")!.handler({ taskId: "task-1", title: "updated" }); + assert.deepEqual((update._meta?.["siftable/execution"] as any).idempotency, { disposition: "replayed", payloadDigest: "abc" }); + assert.equal((update._meta?.["siftable/execution"] as any).structuredResult.schemaId, "sift.task.mutation.v1"); + assert.deepEqual(update.structuredContent, update.data); + const failed = await tools.find((tool) => tool.name === "task_create")!.handler({ title: "sample" }); + assert.equal(failed.success, false); + assert.equal(failed.executionOutcome, "unknown"); + }); +} diff --git a/test-client/run-package-tests.mjs b/test-client/run-package-tests.mjs new file mode 100644 index 0000000..3b990d7 --- /dev/null +++ b/test-client/run-package-tests.mjs @@ -0,0 +1,72 @@ +/** Real tarball consumer checks, not repository-relative source import probes. */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const scratch = mkdtempSync(join(tmpdir(), "openfunction-package-")); +const consumer = join(scratch, "consumer"); +function run(command, args, cwd = consumer) { + return execFileSync(command, args, { cwd, encoding: "utf8", env: { ...process.env, DATABASE_URL: "" }, timeout: command === "npm" ? 180000 : 30000, stdio: ["ignore", "pipe", "pipe"] }); +} +try { + mkdirSync(consumer); + writeFileSync(join(consumer, "package.json"), JSON.stringify({ private: true, type: "module" })); + const pack = JSON.parse(run("npm", ["pack", "--json", "--pack-destination", scratch], root))[0]; + const names = pack.files.map((file) => file.path); + assert.ok(names.includes("dist/src/cli.js")); + assert.ok(names.includes("dist/system-prompts/default.md")); + assert.ok(names.every((name) => name.startsWith("dist/") || ["package.json", "README.md", "LICENSE"].includes(name)), "tarball leaked development files"); + run("npm", ["install", "--ignore-scripts", "--omit=optional", "--no-audit", "--no-fund", join(scratch, pack.filename)]); + assert.ok(names.every((name) => !/^(?:dist\/(?:test-client|scripts|gemini-bridge)\/|\.env|\.data)/.test(name)), "tarball leaked development runtime or state"); + const installed = join(consumer, "node_modules/openfunction"); + const pkg = JSON.parse(readFileSync(join(installed, "package.json"), "utf8")); + const subpaths = Object.keys(pkg.exports).map((key) => key === "." ? "openfunction" : `openfunction${key.slice(1)}`); + // Resolve pg only on demand and share concurrent pool initialization. + run("node", ["--input-type=module", "-e", `process.env.DATABASE_URL = 'postgres://fixture'; const m = await import('openfunction/framework/pg-store'); await m.closePgPool(); await m.createPgStore('missing').then(() => { throw Error('missing peer accepted'); }, e => { if (!e.message.includes('optional pg')) throw e; });`]); + const fakePg = join(consumer, "node_modules/pg"); mkdirSync(fakePg); + writeFileSync(join(fakePg, "package.json"), JSON.stringify({ name: "pg", version: "8.22.0", type: "module", main: "index.js" })); + writeFileSync(join(fakePg, "index.js"), `globalThis.pgLoads = (globalThis.pgLoads ?? 0) + 1; export default { Pool: class { constructor() { globalThis.pgPools = (globalThis.pgPools ?? 0) + 1; } async query() { return {rows: []}; } async end() { globalThis.pgEnds = (globalThis.pgEnds ?? 0) + 1; } } };`); + run("node", ["--input-type=module", "-e", `process.env.DATABASE_URL = 'postgres://fixture'; const m = await import('openfunction/framework/pg-store'); if (globalThis.pgLoads) throw Error('eager load'); const [a,b] = await Promise.all([m.createPgStore('a'), m.createPgStore('b')]); a.set('x', 1); b.set('y', 2); await m.closePgPool(); if (globalThis.pgLoads !== 1 || globalThis.pgPools !== 1 || globalThis.pgEnds !== 1) throw Error('pool sharing/close failed');`]); + rmSync(fakePg, { recursive: true, force: true }); + writeFileSync(join(consumer, ".env"), "OPENFUNCTION_PACKAGE_IMPORT_SENTINEL=loaded\n"); + // Install optional dev versions only after the minimal root/core proof. + run("node", ["--input-type=module", "-e", `const root = await import('openfunction'); await import('openfunction/framework'); await import('openfunction/framework/core'); await import('openfunction/framework/pg-store').then(m => m.closePgPool()); if (root.registry.getAll().length) throw Error('import registered tools'); if (process.env.OPENFUNCTION_PACKAGE_IMPORT_SENTINEL) throw Error('import loaded .env');`]); + assert.equal(existsSync(join(consumer, ".data")), false, "library imports created store files"); + run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", `pg@${pkg.devDependencies.pg}`, `@siftable/mcp-server@${pkg.devDependencies['@siftable/mcp-server']}`]); + const imports = run("node", ["--input-type=module", "-e", `for (const name of ${JSON.stringify(subpaths)}) await import(name);`]); + assert.equal(imports, "", "public imports wrote to stdout"); + const bin = join(consumer, "node_modules/.bin/openfunction"); + assert.match(run(bin, ["help"]), /serve --module/); + const unknown = spawnSync(bin, ["unknown"], { cwd: consumer, encoding: "utf8" }); + assert.equal(unknown.status, 1); assert.equal(unknown.stdout, ""); + assert.match(unknown.stderr, /Unknown command/); + const typeSource = `import { createStore, type Store, type PgStore } from "openfunction";\nconst store: Store<{count:number}> = createStore("typed", {dataDir:"caller-state"}); store.set("x", {count:1});\nconst acknowledge = (store: PgStore): Promise => store.flush(); void acknowledge;\n` + subpaths.map((name, index) => `import * as entry${index} from ${JSON.stringify(name)}; void entry${index};`).join("\n"); + writeFileSync(join(consumer, "consumer.ts"), typeSource); + writeFileSync(join(consumer, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2022", module: "Node16", moduleResolution: "Node16", strict: true, skipLibCheck: true, noEmit: true } })); + run(join(root, "node_modules/.bin/tsc"), ["-p", join(consumer, "tsconfig.json")]); + // Read-only package install must load presets and write only caller-owned state. + function readonly(path) { + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name); if (entry.isDirectory()) readonly(child); else chmodSync(child, 0o444); + } + chmodSync(path, 0o555); + } + readonly(installed); + run("node", ["--input-type=module", "-e", `import {ToolRegistry, createStore, loadPromptPreset, listPresets} from 'openfunction'; if (!listPresets().includes('default')) throw Error('no default'); if (!loadPromptPreset('default', new ToolRegistry())) throw Error('empty preset'); createStore('caller').set('x', 1); createStore('override', {dataDir: ${JSON.stringify(join(scratch, "explicit-data"))}}).set('x', 2);`]); + assert.ok(existsSync(join(consumer, ".data/caller.json"))); + assert.ok(existsSync(join(scratch, "explicit-data/override.json"))); + assert.equal(existsSync(join(installed, ".data")), false); + console.log(`Package verified (${process.version}): ${subpaths.length} public imports, declarations, compiled CLI, presets, caller-owned stores, optional-backend-free core.`); +} finally { + // Restore directory write access before deleting the read-only install. + function restoreDirectories(path) { + if (!existsSync(path)) return; + chmodSync(path, 0o700); + for (const entry of readdirSync(path, { withFileTypes: true })) if (entry.isDirectory()) restoreDirectories(join(path, entry.name)); + } + restoreDirectories(scratch); + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/test-client/run-pg-store-tests.ts b/test-client/run-pg-store-tests.ts new file mode 100644 index 0000000..0c09700 --- /dev/null +++ b/test-client/run-pg-store-tests.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createPgStore, closePgPool, type PgStoreDatabase } from "../src/framework/pg-store.js"; + +function fixture() { + const persisted = new Map(); + const calls: string[] = []; + let release: (() => void) | undefined; + let fail = false; + let gate: Promise = Promise.resolve(); + const database: PgStoreDatabase = { + async query(sql, values) { + calls.push(sql); + if (sql.startsWith("SELECT")) return { rows: [] }; + if (sql.startsWith("CREATE")) return { rows: [] }; + await gate; + if (fail) throw new Error("injected write failure"); + if (sql.startsWith("INSERT")) persisted.set(String(values?.[0]), JSON.parse(String(values?.[1]))); + else if (values) persisted.delete(String(values[0])); + else persisted.clear(); + return { rows: [] }; + }, + }; + return { database, persisted, calls, + hold() { gate = new Promise((resolve) => { release = resolve; }); }, + release() { release?.(); gate = Promise.resolve(); }, + fail() { fail = true; }, + }; +} + +test("PgStore flush waits for persistence and preserves sync Store API", async () => { + const db = fixture(); + const store = await createPgStore<{ count: number }>("flush_fixture", { database: db.database }); + db.hold(); + store.set("one", { count: 1 }); + assert.deepEqual(store.get("one"), { count: 1 }); + assert.equal(db.persisted.size, 0); + let flushed = false; + const barrier = store.flush().then(() => { flushed = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(flushed, false); + db.release(); + await barrier; + assert.deepEqual(db.persisted.get("one"), { count: 1 }); + await closePgPool(); +}); + +test("PgStore persists ordered set/delete/clear operations", async () => { + const db = fixture(); + const store = await createPgStore("ordered", { database: db.database }); + store.set("one", 1); store.delete("one"); store.set("two", 2); store.clear(); store.set("last", 3); + await store.flush(); + assert.deepEqual([...db.persisted], [["last", 3]]); + assert.deepEqual(db.calls.slice(2).map((sql) => sql.split(" ")[0]), ["INSERT", "DELETE", "INSERT", "DELETE", "INSERT"]); + await closePgPool(); + assert.throws(() => store.set("closed", 4), /closed/); +}); + +test("PgStore flush and shutdown surface persistence errors", async () => { + const db = fixture(); + const store = await createPgStore("failed", { database: db.database }); + db.fail(); store.set("one", 1); + await assert.rejects(store.flush(), (error: unknown) => + error instanceof AggregateError && error.errors[0].message === "injected write failure"); + await assert.rejects(closePgPool(), /Postgres shutdown failed/); + await closePgPool(); +}); + +test("PgStore validates identifiers and JSON before acknowledging cache changes", async () => { + const db = fixture(); + await assert.rejects(createPgStore('', { database: db.database }), /store name/); + const store = await createPgStore("valid", { database: db.database }); + assert.throws(() => store.set("bad", undefined), /JSON serializable/); + assert.equal(store.has("bad"), false); + await closePgPool(); +}); + + +test("PgStore initialization cannot register after concurrent shutdown", async () => { + let release!: () => void; + const barrier = new Promise((resolve) => { release = resolve; }); + const database: PgStoreDatabase = { async query() { await barrier; return { rows: [] }; } }; + const opening = createPgStore("initializing", { database }); + await closePgPool(); + release(); + await assert.rejects(opening, /shut down during store initialization/); + await closePgPool(); +}); diff --git a/test-client/run-runtime-bridge-tests.ts b/test-client/run-runtime-bridge-tests.ts new file mode 100644 index 0000000..eb301d7 --- /dev/null +++ b/test-client/run-runtime-bridge-tests.ts @@ -0,0 +1,107 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { defineTool, ok } from "../src/framework/tool.js"; +import { toPiTools } from "../src/framework/pi.js"; +import { toOpenclawTools, toOpenclawToolPluginTools } from "../src/framework/openclaw.js"; +import type { ToolExecutionReceipt } from "../src/framework/execution-types.js"; + +const schema = { type: "object", properties: {} } as const; + +test("native bridges preserve text and images and explicitly project unsupported resource content", async () => { + const registry = new ToolRegistry(); + registry.register(defineTool({ + name: "rich_content", description: "Return rich blocks", inputSchema: schema, + handler: async () => ({ success: true, content: [ + { type: "text", text: "caption" }, + { type: "image", data: "aW1hZ2U=", mimeType: "image/png" }, + { type: "resource", resource: { uri: "file:///proof.txt", mimeType: "text/plain", text: "proof" } }, + { type: "audio", data: "YXVkaW8=", mimeType: "audio/wav" }, + ] }), + })); + const [pi] = toPiTools(registry); + const piResult = await pi.execute("pi-rich", {}, undefined, undefined, {}); + const [openclaw] = toOpenclawTools(registry); + const clawResult = await openclaw.execute("claw-rich", {}); + assert.deepEqual(clawResult.content, piResult.content); + assert.deepEqual(piResult.content.slice(0, 2), [ + { type: "text", text: "caption" }, + { type: "image", data: "aW1hZ2U=", mimeType: "image/png" }, + ]); + assert.match(JSON.stringify(piResult.content[2]), /proof.txt.*not supported.*proof/); + assert.match(JSON.stringify(piResult.content[3]), /Unsupported audio content omitted/); + assert.equal(piResult.details.executionReceipt?.callId, "pi-rich"); + assert.equal(clawResult.details?.executionReceipt.callId, "claw-rich"); + const [plugin] = toOpenclawToolPluginTools(registry); + const pluginResult = await plugin.execute({}, {}, { toolCallId: "plugin-rich" }) as { content: unknown[]; details: { executionReceipt: ToolExecutionReceipt } }; + assert.deepEqual(pluginResult.content, piResult.content); + assert.equal(pluginResult.details.executionReceipt.callId, "plugin-rich"); +}); + +test("abort after a committed handler result preserves native result and receipt", async () => { + const registry = new ToolRegistry(); const controller = new AbortController(); + registry.register(defineTool({ + name: "commit_then_abort", description: "Commit and cancel", inputSchema: schema, + handler: async (_params, context) => { + assert.equal(context?.signal?.aborted, false); + controller.abort(); return ok({ committed: true }); + }, + })); + const [tool] = toPiTools(registry); + const result = await tool.execute("commit", {}, controller.signal, undefined, {}); + assert.equal(result.details.success, true); + assert.deepEqual(result.details.data, { committed: true }); + assert.equal(result.details.executionReceipt?.cancellationObserved, true); + assert.equal(result.details.executionReceipt?.executionOutcome, "succeeded"); +}); + +test("host trusted resolver supplies execution context; model arguments cannot supply authority", async () => { + const registry = new ToolRegistry(); + registry.register(defineTool({ + name: "context_probe", description: "Inspect trusted context", inputSchema: schema, + handler: async (_params, context) => ok({ actor: context?.actor, scope: context?.scope }), + })); + const [tool] = toOpenclawTools(registry, { + resolveExecutionContext: ({ toolCallId }) => { + assert.equal(toolCallId, "context"); + return { actor: { kind: "host", id: "trusted-app" }, scope: { workspaceId: "allowed" } }; + }, + }); + const result = await tool.execute("context", { actor: { id: "forged" }, scope: { workspaceId: "other" } }); + assert.deepEqual(result.details?.result.data, { actor: { kind: "host", id: "trusted-app" }, scope: { workspaceId: "allowed" } }); +}); + +test("native formatters receive canonical data after one serialization; ordinary details stay compatible", async () => { + const registry = new ToolRegistry(); let calls = 0; let formatted = 0; + registry.register(defineTool({ + name: "serialize_once", description: "Observe serialization", inputSchema: schema, + handler: async () => ok({ toJSON() { calls++; return { stable: true }; } }), + })); + const [tool] = toOpenclawTools(registry, { formatResult: (result) => { + formatted++; assert.deepEqual(result.data, { stable: true }); + return { type: "text", content: [{ type: "text", text: "custom" }] }; + } }); + assert.deepEqual(await tool.execute("serialize", {}), { type: "text", content: [{ type: "text", text: "custom" }] }); + assert.equal(calls, 1); assert.equal(formatted, 1); +}); + +test("unknown native effects remain explicit and failed Pi calls carry recovery receipts", async () => { + const registry = new ToolRegistry(); + registry.register(defineTool({ + name: "uncertain_effect", description: "Throw after possible effect", inputSchema: schema, + handler: async () => { throw new Error("transport disconnected"); }, + })); + const [claw] = toOpenclawTools(registry); + const result = await claw.execute("unknown-claw", {}); + assert.equal(result.isError, true); + assert.match(JSON.stringify(result.content), /unknown.*verify side effects/i); + assert.equal(result.details?.executionReceipt.executionOutcome, "unknown"); + const [pi] = toPiTools(registry); + await assert.rejects(pi.execute("unknown-pi", {}, undefined, undefined, {}), (error: unknown) => { + assert.match((error as Error).message, /outcome is unknown; verify side effects/); + const receipt = (error as { executionReceipt: ToolExecutionReceipt }).executionReceipt; + assert.equal(receipt.executionOutcome, "unknown"); + assert.equal(receipt.callId, "unknown-pi"); + return true; + }); +}); diff --git a/test-client/run-session-v2-tests.ts b/test-client/run-session-v2-tests.ts new file mode 100644 index 0000000..67a1180 --- /dev/null +++ b/test-client/run-session-v2-tests.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env tsx +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync, statSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SessionKernel, InMemorySessionEventStore, JsonlSessionEventStore, digestJson, type SessionEvent } from "../src/framework/session.js"; +import type { ToolExecutionReceipt } from "../src/framework/execution-types.js"; + +const call = { id: "call-1", name: "write", args: { value: 2 } }; +const context = { turnId: "turn-1", stepId: "step-1", runId: "run-1", correlationId: "correlation-1" }; +function declared(session = new SessionKernel({ sessionId: "v2" })): SessionKernel { + session.append({ type: "turn/started", data: {}, turnId: context.turnId, runId: context.runId, correlationId: context.correlationId }); + session.append({ type: "step/started", data: {}, ...context }); + session.append({ type: "model/request-prepared", data: { + history: { throughSeq: session.getEventCount(), messageCount: session.getHistory().length, sha256: digestJson(session.getHistory()) }, + systemPromptSha256: digestJson("prompt"), + tools: { names: [call.name], sha256: digestJson([{ name: call.name }]) }, + }, ...context }); + session.append({ type: "model/response-received", data: { toolCall: call }, ...context }); + session.appendMessage({ role: "assistant", toolCallId: call.id, toolName: call.name, content: JSON.stringify(call.args) }, context); + session.append({ type: "tool/call", data: { call }, ...context }); + return session; +} +function receipt(overrides: Partial = {}): ToolExecutionReceipt { + return { + schemaVersion: 1, executionId: "execution-1", callId: call.id, toolName: call.name, + runId: context.runId, correlationId: context.correlationId, + inputDigest: digestJson(call.args), capabilityDigest: digestJson({ name: call.name }), + admission: "rejected", reasonCodes: ["policy_denied"], authorityGrantRefs: [], + phase: "not_started", executionOutcome: "failed", requestedAt: 1, finishedAt: 2, + cancellationObserved: false, deadlineObserved: false, + idempotency: { disposition: "not_requested" }, replayPolicy: "never", artifactRefs: [], + verification: { state: "not_requested", evidenceRefs: [] }, ...overrides, + }; +} +function result(session: SessionKernel, bound: ToolExecutionReceipt): void { + const value = { success: false, error: "denied", executionOutcome: "failed" }; + session.append({ type: "tool/result", data: { + toolCallId: call.id, toolName: call.name, outcome: "failed", result: value, + modelContent: "EXACT denied receipt", receipt: bound, + }, ...context }); +} + +test("session v2: bound rejection can settle without a fake execution start", () => { + const session = declared(); + result(session, receipt()); + assert.ok(session.getEvents().every((event) => event.schemaVersion === 2)); + assert.equal(session.getEvents().filter((event) => event.type === "tool/execution-started").length, 0); + const replayed = new SessionKernel({ sessionId: "v2", store: new InMemorySessionEventStore(session.getEvents()) }); + replayed.recoverInterrupted(); + assert.equal(replayed.getHistory().find((message) => message.role === "tool")?.content, "EXACT denied receipt"); +}); + +test("session v2: forged rejection binding and contradictory outcome fail closed", () => { + for (const changed of [ + { callId: "other" }, { toolName: "other" }, { inputDigest: digestJson({ value: 3 }) }, + { runId: "other" }, { correlationId: "other" }, { phase: "settled" }, + { admission: "allowed" }, { executionOutcome: "succeeded" }, + ] as Partial[]) { + const session = declared(); + assert.throws(() => result(session, receipt(changed)), /receipt|execution-started/); + assert.equal(session.getEvents().at(-1)?.type, "tool/call"); + } +}); + +test("session v2: execution identity and capability cannot change after actual start", () => { + for (const changed of [{ executionId: "other" }, { capabilityDigest: "other" }]) { + const session = declared(); + session.append({ type: "tool/execution-started", data: { call, receipt: receipt({ admission: "allowed", phase: "started", executionOutcome: "unknown" }) }, ...context }); + assert.throws(() => result(session, receipt({ admission: "allowed", phase: "settled", ...changed })), /identity|capability/); + } +}); + +test("session v2: v1 started crash is read unchanged and recovers as unknown without effects", () => { + const original = declared(); + original.append({ type: "tool/execution-started", data: { call }, ...context }); + const legacy = original.getEvents().map((event) => ({ ...event, schemaVersion: 1 as const })) as SessionEvent[]; + const bytes = JSON.stringify(legacy); + const recovered = new SessionKernel({ sessionId: "v2", store: new InMemorySessionEventStore(legacy) }); + assert.equal(JSON.stringify(recovered.getEvents()), bytes); + const added = recovered.recoverInterrupted(); + assert.ok(added.every((event) => event.schemaVersion === 2)); + const settlement = added.find((event) => event.type === "tool/result"); + assert.equal(settlement?.type === "tool/result" ? settlement.data.outcome : undefined, "unknown"); + assert.equal(JSON.stringify(recovered.getEvents().slice(0, legacy.length)), bytes); + const replay = new SessionKernel({ sessionId: "v2", store: new InMemorySessionEventStore(recovered.getEvents()) }); + assert.deepEqual(replay.recoverInterrupted(), []); +}); + +test("session v2: pre-upgrade copy preserves exact original bytes including torn tail", (t) => { + const root = mkdtempSync(join(tmpdir(), "session-v2-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const original = new SessionKernel({ sessionId: "legacy" }); + const legacy = original.getEvents().map((event) => ({ ...event, schemaVersion: 1 })); + const file = join(root, `${createHash("sha256").update("legacy").digest("hex")}.jsonl`); + const bytes = Buffer.from(`${JSON.stringify(legacy[0], null, 0)}\n{"torn":`, "utf8"); + writeFileSync(file, bytes, { mode: 0o600 }); + const upgraded = new SessionKernel({ sessionId: "legacy", store: new JsonlSessionEventStore({ rootDir: root }) }); + assert.deepEqual(readFileSync(file), bytes); + upgraded.appendMessage({ role: "user", content: "new message" }); + assert.deepEqual(readFileSync(`${file}.pre-v2`), bytes); + assert.equal(statSync(`${file}.pre-v2`).mode & 0o777, 0o600); + const after = readFileSync(file, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(after.map((event) => event.schemaVersion), [1, 2]); + upgraded.appendMessage({ role: "user", content: "another" }); + assert.deepEqual(readFileSync(`${file}.pre-v2`), bytes); +}); + +test("session v2: unsafe pre-upgrade destination blocks upgrade without modifying v1", (t) => { + const root = mkdtempSync(join(tmpdir(), "session-v2-unsafe-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const file = join(root, `${createHash("sha256").update("legacy").digest("hex")}.jsonl`); + const started = new SessionKernel({ sessionId: "legacy" }).getEvents()[0]; + const bytes = Buffer.from(`${JSON.stringify({ ...started, schemaVersion: 1 })}\n`); + writeFileSync(file, bytes, { mode: 0o600 }); + symlinkSync(file, `${file}.pre-v2`); + const upgraded = new SessionKernel({ sessionId: "legacy", store: new JsonlSessionEventStore({ rootDir: root }) }); + assert.throws(() => upgraded.appendMessage({ role: "user", content: "blocked" }), /symbolic link/); + assert.deepEqual(readFileSync(file), bytes); + assert.equal(upgraded.getEvents().length, 1); +}); + + +test("session v2: no-dispatch settlement keeps durable intent actor and scope binding", () => { + const session = declared(); + session.append({ type: "tool/execution-started", data: { call, receipt: receipt({ admission: "allowed", phase: "started", executionOutcome: "unknown", actor: { kind: "host", id: "trusted" }, scope: { workspaceId: "workspace" } }) }, ...context }); + assert.throws(() => result(session, receipt({ actor: { kind: "host", id: "trusted" }, scope: { workspaceId: "other" } })), /actor\/scope binding/); + assert.throws(() => result(session, receipt({ actor: { kind: "host", id: "forged" }, scope: { workspaceId: "workspace" } })), /actor\/scope binding/); + result(session, receipt({ actor: { kind: "host", id: "trusted" }, scope: { workspaceId: "workspace" } })); + const events = session.getEvents(); + assert.throws(() => new InMemorySessionEventStore(events.map((event) => ({ ...event, schemaVersion: 1 })) as SessionEvent[]), /bound v2 not-started rejection/); +}); diff --git a/test-client/run-siftable-provider-tests.ts b/test-client/run-siftable-provider-tests.ts index 279cddf..37b060c 100644 --- a/test-client/run-siftable-provider-tests.ts +++ b/test-client/run-siftable-provider-tests.ts @@ -10,6 +10,8 @@ import { createSiftableProvider } from "../src/providers/execufunction/index.js" import { createSiftableSdkTools } from "../src/providers/execufunction/sdk-tools.js"; import { createVaultTools, + createCodeMemoryTools, + createCodebaseTools, createWorkItemTools, } from "../src/providers/execufunction/tools.js"; @@ -98,6 +100,7 @@ test("canonical provider surface follows the installed MCP SDK", async () => { const expectedNames = TOOLS .filter((tool) => isToolEnabled(tool.name)) .filter((tool) => isToolAllowedForTransport(tool.name, "hosted_remote")) + .filter((tool) => !tool.name.startsWith("work_session_")) .map((tool) => tool.name); assert.deepEqual(actual.map((tool) => tool.name), expectedNames); @@ -140,6 +143,9 @@ test("canonical handlers delegate to SDK execution and return structured receipt emoji: null, }], nextCursor: null, + page: { returned: 1, nextCursor: null, total: null, complete: true, snapshotId: null }, + omissions: ["totalCount"], + warnings: [], }); assert.match(result.message ?? "", /Alpha/); }); @@ -150,6 +156,9 @@ test("legacy aliases remain explicitly opt-in", async () => { const names = connected.createTools().map((tool) => tool.name); assert.ok(names.includes("task_list")); assert.ok(names.includes("exf_tasks_list")); + assert.equal(names.some((name) => name.startsWith("work_session_")), false); + assert.equal(names.includes("exf_codebase_search"), false); + assert.equal(names.includes("exf_code_who_knows"), false); }); test("metadata describes the graph and distinct planning/execution lifecycles", () => { @@ -159,3 +168,29 @@ test("metadata describes the graph and distinct planning/execution lifecycles", assert.ok(metadata.capabilities.includes("capabilities")); assert.equal(metadata.auth?.envVar, "SIFT_TOKEN"); }); + + +test("legacy factories supply explicit SDK1.5.1 retry keys without adding them to payload", async () => { + const captured: { payload: unknown; key: string }[] = []; + const raw = { + createVaultEntry: async (payload: unknown, key: string) => { captured.push({ payload, key }); return { statusCode: 200, data: { id: "vault-1" } }; }, + storeCodeMemory: async (payload: unknown, key: string) => { captured.push({ payload, key }); return { statusCode: 200, data: { id: "memory-1" } }; }, + }; + const client = clientWithRaw(raw); + const vault = createVaultTools(client).find((tool) => tool.name === "exf_vault_create")!; + await vault.handler({ name: "fixture", payload: { value: "synthetic" }, idempotencyKey: "explicit-key" }); + const memory = createCodeMemoryTools(client).find((tool) => tool.name === "exf_code_memory_store")!; + await memory.handler({ fact: "fixture", category: "test" }, { idempotencyKey: "host-key" }); + assert.deepEqual(captured, [ + { payload: { name: "fixture", payload: { value: "synthetic" } }, key: "explicit-key" }, + { payload: { fact: "fixture", category: "test" }, key: "host-key" }, + ]); + assert.deepEqual(createCodebaseTools(client), []); +}); + + +test("legacy SDK write responses retain uncertainty after upstream dispatch", async () => { + const tools = createWorkItemTools(clientWithRaw({ claimWorkItem: async () => ({ statusCode: 503, error: "response interrupted" }) })); + const claim = tools.find((tool) => tool.name === "exf_work_item_claim")!; + assert.equal((await claim.handler({ claimOwner: "fixture" })).executionOutcome, "unknown"); +}); diff --git a/test-client/run-siftable-work-provider-tests.ts b/test-client/run-siftable-work-provider-tests.ts new file mode 100644 index 0000000..8db6bc6 --- /dev/null +++ b/test-client/run-siftable-work-provider-tests.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env tsx +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { createSiftableMcpProvider, createSiftableWorkBackend, parseSiftableActionReceipt, SIFTABLE_HOSTED_RECOVERY_STATUS } from "../src/providers/siftable-work/index.js"; + +const schema = { type: "object" as const, properties: {} }; +function receipt(outcome: "rejected" | "failed" | "succeeded", operation = "work_session_transition") { + return { + kind: "sift.action_receipt", schemaVersion: 1, receiptId: "receipt-1", + completedAt: "2026-10-12T00:00:00.000Z", surface: "mcp", + binding: { status: "unbound", action: null }, operation: { identifier: operation, schemaDigest: null }, + outcome, result: outcome === "succeeded" ? { contentType: "application/json", schemaId: "sift.work.lease-session.v1", value: { state: "active" } } : null, + error: outcome === "succeeded" ? null : { code: "not_allowed", message: "connection refused", recovery: "check scope", details: null }, + }; +} +async function hostedFixture(principalKind = "user", authority = "validated_token") { + const server = new Server({ name: "siftable-fixture", version: "1.5.1" }, { capabilities: { tools: {} } }); + let calls = 0; + const names = ["context_current", "work_item_get", "work_item_verification_plan_get", "work_item_verification_evidence_submit", "work_session_open", "work_session_transition", "work_session_close", "work_item_claim", "vault_read"]; + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: names.map((name) => ({ + name, description: `Fixture ${name}`, inputSchema: name === "work_item_verification_evidence_submit" + ? { type: "object" as const, properties: { workItemId: { type: "string" }, claimToken: { type: "string" }, claimOwner: { type: "string" } } } : schema, + annotations: { readOnlyHint: name === "context_current" || name.endsWith("_get") }, + })) })); + server.setRequestHandler(CallToolRequestSchema, async (request) => { + calls++; + if (request.params.name === "context_current") return { + content: [{ type: "text", text: "authenticated context" }], + structuredContent: { contractVersion: 1, principal: { id: "authenticated-user", kind: principalKind, authority }, + queryContext: { selectedWorkspaceContextId: null }, project: null, projectCandidates: [{ id: "candidate", name: "do not infer" }], authorization: { effectiveScopes: ["work:read"] } }, + }; + if (request.params.name.startsWith("work_session_")) return { + content: [{ type: "text", text: "private-custody-fixture" }], + structuredContent: { ...receipt("succeeded", request.params.name), result: { + contentType: "application/json", schemaId: "sift.work.lease-session.v1", value: { sessionHandle: "private-custody-fixture", state: "active" }, + } }, + }; + if (request.params.name === "work_item_verification_evidence_submit") return { + content: [{ type: "text", text: "Evidence submitted" }], + structuredContent: { ...receipt("succeeded", request.params.name), result: { + contentType: "application/json", schemaId: "sift.work.evidence.v1", value: { attempt: { id: "evidence-1" } }, + } }, + }; + const outcome = request.params.arguments?.outcome === "rejected" ? "rejected" : "failed"; + return { content: [{ type: "text", text: "Error: response uncertain" }], structuredContent: receipt(outcome, request.params.name), isError: true }; + }); + const [left, right] = InMemoryTransport.createLinkedPair(); + await server.connect(right); + return { server, get calls() { return calls; }, createTransport: () => left }; +} + +test("hosted Siftable binds authenticated identity and keeps null project without picking a candidate", async () => { + const upstream = await hostedFixture(); + const provider = await createSiftableMcpProvider({ createTransport: upstream.createTransport }).connect(); + try { + assert.equal(provider.authenticatedContext.principalId, "authenticated-user"); + assert.equal(provider.authenticatedContext.projectId, null); + assert.equal(provider.executionContext.scope?.projectId, undefined); + assert.equal(provider.executionContext.actor?.id, "authenticated-user"); + const tools = provider.createTools(); + assert.equal(tools.some((tool) => tool.name === "work_item_claim" || tool.name === "vault_read"), false); + assert.equal(provider.hostedSessionsAvailable, true); + assert.equal(tools.some((tool) => tool.name.startsWith("work_session_")), false); + assert.equal(tools.some((tool) => tool.name === "work_item_verification_evidence_submit"), false); + assert.equal(provider.evidenceSubmissionAvailable, false); + const registry = new ToolRegistry(); + registry.registerAll(tools); + const unavailable = await registry.executeDetailed("work_session_open", { claimOwner: "model-asserted" }); + assert.equal(unavailable.receipt.phase, "not_started"); + assert.equal(unavailable.modelContent.includes("private-custody-fixture"), false); + assert.equal(upstream.calls, 1, "default profile cannot dispatch session operations"); + const read = tools.find((tool) => tool.name === "work_item_get")!; + assert.equal((await read.handler({ workspaceId: "model-selected-other-scope" })).executionOutcome, "failed"); + assert.equal(upstream.calls, 1, "model workspace arguments cannot change the host binding"); + assert.equal((await read.handler({ outcome: "failed" })).executionOutcome, "unknown"); + assert.equal((await read.handler({ outcome: "rejected" })).executionOutcome, "failed"); + await assert.rejects(provider.refreshCatalog(), /Reconnect Siftable/); + assert.throws(createSiftableWorkBackend, /unavailable/); + assert.equal(upstream.calls, 3, "disabled durable backend never claims work"); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } +}); + +test("hosted scope mismatch and unvalidated principal close the connection before work calls", async () => { + for (const kind of ["workspace", "principal"]) { + const upstream = await hostedFixture("user", kind === "principal" ? "local_configuration" : "validated_token"); + try { + const provider = createSiftableMcpProvider({ createTransport: upstream.createTransport, ...(kind === "workspace" ? { expectedWorkspaceId: "other-workspace" } : {}) }); + await assert.rejects(provider.connect(), kind === "workspace" ? /workspace.*match/ : /validated authenticated principal/); + assert.equal(upstream.calls, 1); + } finally { + await upstream.server.close(); + } + } + assert.throws(() => createSiftableMcpProvider({ toolAllowlist: ["context_current", "work_item_claim"] }), /raw lifecycle/); +}); + +test("workspace-service identity cannot expose evidence requiring raw lease custody", async () => { + const upstream = await hostedFixture("workspace_service"); + const provider = await createSiftableMcpProvider({ createTransport: upstream.createTransport }).connect(); + try { + assert.equal(provider.evidenceSubmissionAvailable, false); + assert.equal(provider.createTools().some((tool) => tool.name === "work_item_verification_evidence_submit"), false); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } +}); + +test("explicit evidence selection requires trusted identity and validated execution policy", async () => { + const upstream = await hostedFixture(); + let hostIdentity = "wrong-principal"; + let hostGrant = "unvalidated-reference"; + let authorizations = 0; + const provider = await createSiftableMcpProvider({ + createTransport: upstream.createTransport, + evidenceExecution: { + resolveContext: () => ({ actor: { kind: "host", id: hostIdentity }, scope: {}, authorityGrantRefs: [hostGrant] }), + policy: { authorize({ context }) { + authorizations++; + return context.actor?.id === "authenticated-user" && context.authorityGrantRefs?.includes("validated-host-evidence") + ? { decision: "allow", authorityGrantRefs: ["validated-host-evidence"] } + : { decision: "approval_required", reasonCodes: ["unvalidated_evidence_grant"] }; + } }, + }, + }).connect(); + try { + const evidence = provider.createTools().find((tool) => tool.name === "work_item_verification_evidence_submit")!; + assert.equal(provider.evidenceSubmissionAvailable, true); + assert.equal(evidence.contract?.requiresAuthority, true); + assert.equal(evidence.inputSchema.properties.claimToken, undefined); + // Caller assertions cannot replace the trusted resolver's identity. + assert.equal((await evidence.handler({ workItemId: "work-1" }, { actor: { kind: "host", id: "authenticated-user" }, authorityGrantRefs: ["validated-host-evidence"] })).executionOutcome, "failed"); + assert.equal(authorizations, 0); + assert.equal(upstream.calls, 1); + hostIdentity = "authenticated-user"; + assert.equal((await evidence.handler({ workItemId: "work-1" })).executionOutcome, "failed"); + assert.equal(upstream.calls, 1, "unvalidated host references cannot dispatch evidence"); + hostGrant = "validated-host-evidence"; + assert.equal((await evidence.handler({ workItemId: "work-1", claimToken: "model-forged-custody" })).executionOutcome, "failed"); + assert.equal(upstream.calls, 1); + const accepted = await evidence.handler({ workItemId: "work-1" }); + assert.equal(accepted.success, true); + assert.ok(authorizations >= 3, "policy validates admission and dispatch"); + assert.equal(upstream.calls, 2); + assert.equal(JSON.stringify(accepted).includes("private-custody-fixture"), false); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } +}); + +test("explicit allowlists cannot expose hosted session custody tools", async () => { + const upstream = await hostedFixture(); + const provider = await createSiftableMcpProvider({ createTransport: upstream.createTransport, + toolAllowlist: ["context_current", "work_session_open", "work_session_transition", "work_session_close"], + }).connect(); + try { + assert.equal(provider.hostedSessionsAvailable, true); + assert.deepEqual(provider.createTools().map((tool) => tool.name), ["context_current"]); + assert.equal(upstream.calls, 1); + } finally { + await provider.disconnect(); + await upstream.server.close(); + } +}); + +test("receipt parser requires a canonical matching operation and never treats failed as rejected", () => { + assert.equal(parseSiftableActionReceipt(receipt("rejected"), "work_session_transition")?.outcome, "rejected"); + assert.equal(parseSiftableActionReceipt(receipt("failed"), "work_session_transition")?.outcome, "failed"); + assert.equal(parseSiftableActionReceipt(receipt("rejected"), "different_operation"), undefined); + assert.equal(parseSiftableActionReceipt({ ...receipt("rejected"), result: { value: "not a rejection" } }), undefined); + assert.equal(parseSiftableActionReceipt({ outcome: "rejected", error: "Error: failed" }), undefined); +}); + +test("inspected published recovery matrix keeps unproved retry guarantees disabled", () => { + const matrix = JSON.parse(readFileSync(new URL("./fixtures/siftable-work-recovery-1.5.1.json", import.meta.url), "utf8")); + assert.equal(matrix.package.version, "1.5.1"); + assert.equal(matrix.status, "blocked"); + assert.equal(matrix.capabilities.automaticRecoveryEnabled, false); + assert.deepEqual(matrix.capabilities.keyedOperations, []); + assert.equal(matrix.operations.find((operation: any) => operation.operation === "evidence").transportKeyField, null); + assert.equal(SIFTABLE_HOSTED_RECOVERY_STATUS.enabled, false); +}); diff --git a/test-client/run-tool-scheduler-tests.ts b/test-client/run-tool-scheduler-tests.ts new file mode 100644 index 0000000..52dcb94 --- /dev/null +++ b/test-client/run-tool-scheduler-tests.ts @@ -0,0 +1,116 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ToolScheduler, type BoundToolScheduler } from "../src/framework/tool-scheduler.js"; + +function gate() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +async function tick() { await new Promise((done) => setImmediate(done)); } + +test("parallel-safe admissions are bounded across batches; exclusive is a FIFO barrier", async () => { + const scheduler = new ToolScheduler({ maxParallel: 2 }); + const starts: string[] = []; + const a = gate(), b = gate(), writer = gate(), last = gate(); + const run = (name: string, mode: "parallel-safe" | "exclusive", hold: ReturnType) => + scheduler.run(mode, async () => { starts.push(name); await hold.promise; return name; }); + const firstBatch = [run("a", "parallel-safe", a), run("b", "parallel-safe", b)]; + const secondBatch = [run("write", "exclusive", writer), run("last", "parallel-safe", last)]; + assert.deepEqual(starts, ["a", "b"]); + a.resolve(); await tick(); + assert.deepEqual(starts, ["a", "b"]); + b.resolve(); await tick(); + assert.deepEqual(starts, ["a", "b", "write"]); + writer.resolve(); await tick(); + assert.deepEqual(starts, ["a", "b", "write", "last"]); + last.resolve(); + assert.deepEqual(await Promise.all([...firstBatch, ...secondBatch]), ["a", "b", "write", "last"]); +}); + +test("default cap is four; invalid caps safely degrade to one", async () => { + assert.equal(new ToolScheduler().maxParallel, 4); + for (const cap of [NaN, Infinity, -1, 0, 1.5]) { + const scheduler = new ToolScheduler({ maxParallel: cap }); + assert.equal(scheduler.maxParallel, 1); + const first = gate(); let started = 0; + const a = scheduler.run("parallel-safe", async () => { started++; await first.promise; }); + const b = scheduler.run("parallel-safe", async () => { started++; }); + assert.equal(started, 1); + first.resolve(); await Promise.all([a, b]); + assert.equal(started, 2); + } +}); + +test("abort removes queued calls, preserves started result, and permits later admissions", async () => { + const scheduler = new ToolScheduler(); const controller = new AbortController(); const first = gate(); + const starts: number[] = []; + const a = scheduler.run("exclusive", async () => { starts.push(1); await first.promise; return "effect committed"; }, { signal: controller.signal }); + const b = scheduler.run("exclusive", async () => { starts.push(2); }, { signal: controller.signal }); + const rejected = assert.rejects(b, { name: "AbortError" }); + controller.abort(); await rejected; + const c = scheduler.run("exclusive", async () => { starts.push(3); }); + first.resolve(); + assert.equal(await a, "effect committed"); await c; + assert.deepEqual(starts, [1, 3]); +}); + +test("absolute deadline prevents queued admission using supplied clock", async () => { + const scheduler = new ToolScheduler(); const hold = gate(); let now = 100; let called = false; + const a = scheduler.run("exclusive", async () => { await hold.promise; }); + const b = scheduler.run("exclusive", async () => { called = true; }, { deadline: 200, clock: () => now }); + const rejected = assert.rejects(b, { name: "TimeoutError" }); + now = 201; hold.resolve(); await a; await rejected; + assert.equal(called, false); +}); + +test("nested trusted reservation does not deadlock and serializes exclusive siblings", async () => { + const scheduler = new ToolScheduler(); const hold = gate(); const starts: string[] = []; + let retired!: BoundToolScheduler; + const result = scheduler.run("exclusive", async (child) => { + retired = child; + const first = child.run("exclusive", async (grandchild) => { + starts.push("first"); + await grandchild.run("exclusive", async () => { starts.push("grandchild"); }); + await hold.promise; + }); + const second = child.run("exclusive", async () => { starts.push("second"); return 7; }); + await first; return await second; + }); + await tick(); assert.deepEqual(starts, ["first", "grandchild"]); + hold.resolve(); assert.equal(await result, 7); + assert.deepEqual(starts, ["first", "grandchild", "second"]); + await assert.rejects(retired.run("exclusive", async () => {}), /no longer active/); +}); + +test("parallel-safe child cannot upgrade to exclusive", async () => { + const scheduler = new ToolScheduler(); let called = false; + await scheduler.run("parallel-safe", async (child) => { + await assert.rejects(child.run("exclusive", async () => { called = true; }), /Cannot upgrade/); + assert.equal(await child.run("parallel-safe", async () => 8), 8); + }); + assert.equal(called, false); +}); + +test("normal direct admission never inherits nested reservation; unawaited child holds barrier", async () => { + const scheduler = new ToolScheduler(); const hold = gate(); const starts: string[] = []; + const first = scheduler.run("exclusive", async (child) => { + void child.run("exclusive", async () => { starts.push("child"); await hold.promise; }); + }); + const second = scheduler.run("exclusive", async () => { starts.push("second"); }); + await tick(); assert.deepEqual(starts, ["child"]); + hold.resolve(); await Promise.all([first, second]); + assert.deepEqual(starts, ["child", "second"]); +}); + +test("nested options cannot weaken inherited cancellation or deadline", async () => { + const scheduler = new ToolScheduler(); const controller = new AbortController(); let now = 100; + await scheduler.run("exclusive", async (child) => { + now = 201; + await assert.rejects(child.run("exclusive", async () => {}, { deadline: 500 }), { name: "TimeoutError" }); + }, { deadline: 200, clock: () => now }); + await scheduler.run("exclusive", async (child) => { + controller.abort(); + await assert.rejects(child.run("exclusive", async () => {}, { signal: new AbortController().signal }), { name: "AbortError" }); + }, { signal: controller.signal }); +}); diff --git a/test-client/run-validation-tests.ts b/test-client/run-validation-tests.ts new file mode 100644 index 0000000..c073ff3 --- /dev/null +++ b/test-client/run-validation-tests.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env tsx +/** Offline schema/dialect regression tests using Node's test runner. */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateParams, formatValidationErrors } from "../src/framework/validate.js"; +import type { InputSchema, JsonSchemaProperty } from "../src/framework/types.js"; + +function input(property: JsonSchemaProperty): InputSchema { + return { type: "object", properties: { value: property }, required: ["value"] }; +} +function rejects(value: unknown, property: JsonSchemaProperty): void { + assert.notDeepEqual(validateParams({ value }, input(property)), []); +} +function accepts(value: unknown, property: JsonSchemaProperty): void { + assert.deepEqual(validateParams({ value }, input(property)), []); +} + +for (const [name, schema, valid, invalid] of [ + ["anyOf sibling numeric bound", { anyOf: [{ type: "number" }, { type: "null" }], minimum: 3 }, 3, 2], + ["oneOf sibling string pattern", { oneOf: [{ type: "string" }, { type: "null" }], pattern: "^safe$" }, "safe", "unsafe"], + ["allOf sibling string length", { allOf: [{ type: "string" }], minLength: 3 }, "abc", "ab"], + ["anyOf sibling object constraints", { anyOf: [{ type: "object" }], properties: { n: { minimum: 1 } }, required: ["n"], additionalProperties: false }, { n: 1 }, { n: 0, extra: true }], + ["union type", { type: ["integer", "null"], maximum: 3 }, null, 4], + ["constraints without redundant type", { maxLength: 3 }, "abc", "abcd"], + ["deep const", { const: { nested: [{ a: 1, b: 2 }] } }, { nested: [{ b: 2, a: 1 }] }, { nested: [{ a: 1, b: 3 }] }], + ["deep enum", { enum: [{ items: [1, 2] }, null] }, { items: [1, 2] }, { items: [2, 1] }], + ["draft07 tuple", { type: "array", items: [{ type: "string" }, { type: "integer" }], additionalItems: false }, ["a", 1], [1, "a"]], + ["nested additional properties", { type: "object", additionalProperties: { type: "integer" } }, { a: 1 }, { a: "1" }], + ["multipleOf", { type: "number", multipleOf: 2 }, 4, 3], + ["uniqueItems uses structural equality", { type: "array", uniqueItems: true }, [{ a: 1 }, { a: 2 }], [{ a: 1 }, { a: 1 }]], + ["not", { not: { const: "denied" } }, "allowed", "denied"], + ["conditional schemas", { if: { type: "string" }, then: { pattern: "^safe$" }, else: { minimum: 3 } }, 3, "unsafe"], + ["unicode string length", { minLength: 2 }, "😀a", "😀"], +] as const) { + test(`validation: ${name}`, () => { + accepts(valid, schema as JsonSchemaProperty); + rejects(invalid, schema as JsonSchemaProperty); + }); +} + +test("validation: failed union and sibling constraints both survive error projection", () => { + const errors = validateParams({ value: "x" }, input({ anyOf: [{ type: "number" }, { type: "null" }], minLength: 3 })); + assert.equal(errors.length, 2); + assert.ok(errors.some(({ message }) => message.includes("allowed schema"))); + assert.ok(errors.some(({ message }) => message.includes("3 characters"))); +}); + +test("validation: sibling regression rejects the old short-circuit behavior", () => { + // The old validator immediately returned success after anyOf matched. This + // negative control reproduces that admission bug using the same failing input. + const schema: JsonSchemaProperty = { anyOf: [{ type: "number" }], minimum: 3 }; + const oldAccepted = schema.anyOf?.some((branch) => typeof branch === "object" && branch.type === typeof 2); + assert.equal(oldAccepted, true); + rejects(2, schema); +}); + +test("validation: explicit draft2020-12 prefix tuples and unevaluated properties", () => { + const schema: InputSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { value: { type: "array", prefixItems: [{ type: "string" }, { type: "integer" }], items: false } }, + unevaluatedProperties: false, + }; + assert.deepEqual(validateParams({ value: ["a", 1] }, schema), []); + assert.notDeepEqual(validateParams({ value: ["a", 1, 2] }, schema), []); + assert.notDeepEqual(validateParams({ value: ["a", 1], extra: 1 }, schema), []); + assert.notDeepEqual(validateParams({ value: [1, "a"] }, schema), []); +}); + +test("validation: dialects are selected explicitly, never relabeled", () => { + for (const uri of ["http://json-schema.org/draft-07/schema", "http://json-schema.org/draft-07/schema#"]) { + assert.deepEqual(validateParams({ value: ["a"] }, { ...input({ items: [{ type: "string" }] }), $schema: uri }), []); + } + const tuple = { ...input({ items: [{ type: "string" }] }), $schema: "https://json-schema.org/draft/2020-12/schema" }; + assert.equal(validateParams({ value: ["a"] }, tuple)[0].field, "$schema"); + for (const uri of ["https://example.invalid/schema", "http://json-schema.org/draft-04/schema#"]) { + assert.equal(validateParams({}, { type: "object", properties: {}, $schema: uri })[0].field, "$schema"); + } +}); + +for (const [format, valid, invalid] of [ + ["uuid", "550e8400-e29b-41d4-a716-446655440000", "not-a-uuid"], + ["date", "2024-02-29", "2025-02-29"], + ["date-time", "2026-10-12T19:17:04Z", "2026-10-12"], + ["uri", "https://example.test/path", "relative/path"], +] as const) { + test(`validation: ${format} assertions`, () => { + accepts(valid, { type: "string", format }); + rejects(invalid, { type: "string", format }); + }); +} + +test("validation: unsupported formats/keywords and bad schemas fail closed", () => { + for (const property of [ + { format: "unsupported-assertion" }, + { pattern: "[" }, + { minLength: -1 }, + { anyOf: [] }, + { minimum: "1" }, + { madeUpAssertion: true }, + { $ref: "https://example.invalid/remote-schema" }, + { $ref: "#/$defs/missing" }, + { $async: true }, + ]) { + const errors = validateParams({ value: "a" }, input(property as JsonSchemaProperty)); + assert.equal(errors[0]?.field, "$schema", JSON.stringify(property)); + } +}); + +test("validation: local refs resolve independently for schemas with the same $id", () => { + const first: InputSchema = { + $id: "https://example.test/tool", + $defs: { value: { type: "integer", minimum: 2 } }, + type: "object", + properties: { value: { $ref: "#/$defs/value" } }, + }; + assert.deepEqual(validateParams({ value: 2 }, first), []); + assert.notDeepEqual(validateParams({ value: 1 }, first), []); + const second: InputSchema = { ...first, $defs: { value: { type: "integer", maximum: 1 } } }; + assert.deepEqual(validateParams({ value: 1 }, second), []); + assert.notDeepEqual(validateParams({ value: 2 }, second), []); +}); + +test("validation: schema mutations cannot reuse a stale identity-cached validator", () => { + const schema = input({ type: "integer", minimum: 1 }); + assert.deepEqual(validateParams({ value: 2 }, schema), []); + schema.properties.value.minimum = 3; + assert.notDeepEqual(validateParams({ value: 2 }, schema), []); + schema.properties.value.minimum = 1; + assert.deepEqual(validateParams({ value: 2 }, schema), []); +}); + +test("validation: coercion, defaults, and removal remain disabled", () => { + const schema: InputSchema = { type: "object", properties: { n: { type: "integer", default: 5 } }, additionalProperties: false }; + const missing = Object.freeze({}); + assert.deepEqual(validateParams(missing, schema), []); + assert.deepEqual(missing, {}); + const original = { n: "2", extra: true }; + const before = structuredClone(original); + assert.equal(validateParams(original, schema).length, 2); + assert.deepEqual(original, before); +}); + +test("validation: all non-finite/non-JSON input values fail without disclosure", () => { + for (const value of [NaN, Infinity, -Infinity, 1n, undefined, () => 1, new Date()]) { + assert.notDeepEqual(validateParams({ value }, input({})), []); + } + const cycle: Record = {}; + cycle.self = cycle; + assert.notDeepEqual(validateParams(cycle, { type: "object", properties: {} }), []); +}); + +test("validation: safe deterministic paths and errors never contain input values", () => { + const secret = "do-not-disclose-secret-value"; + const schema: InputSchema = { type: "object", properties: { payload: { type: "object", properties: { "a/b~c": { type: "array", items: { type: "integer" } } }, required: ["a/b~c"] } } }; + const errors = validateParams({ payload: { "a/b~c": [secret] } }, schema); + assert.equal(errors[0].field, 'payload["a/b~c"][0]'); + assert.ok(!JSON.stringify(errors).includes(secret)); + assert.ok(!formatValidationErrors("tool", errors).includes(secret)); + const numericKey = validateParams({ value: { "0": secret } }, input({ type: "object", additionalProperties: { type: "integer" } })); + assert.equal(numericKey[0].field, 'value["0"]'); +}); + + +test("validation: required unsupported vocabularies fail rather than silently weaken assertions", () => { + const schema: InputSchema = { type: "object", properties: {}, $schema: "https://json-schema.org/draft/2020-12/schema", $vocabulary: { "https://example.invalid/assertions": true } }; + assert.equal(validateParams({}, schema)[0]?.field, "$schema"); +}); + +test("validation: schema-shaped keys inside const are data, not executable contracts", () => { + accepts({ $async: true, $ref: "https://example.invalid" }, { const: { $async: true, $ref: "https://example.invalid" } }); +}); diff --git a/test-client/run-work-cli-tests.ts b/test-client/run-work-cli-tests.ts new file mode 100644 index 0000000..0ca24b8 --- /dev/null +++ b/test-client/run-work-cli-tests.ts @@ -0,0 +1,67 @@ +/** Drive the real compiled CLI and then use its persisted resolver. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { createLocalFixtureBindings } from "../src/framework/work/local-fixture.js"; +import type { WorkProfileConfig } from "../src/runtime/work-profile.js"; +import { pathToFileURL } from "node:url"; +const createWorkProfile: typeof import("../src/runtime/work-profile.js")["createWorkProfile"] = (await import(pathToFileURL(resolve("dist/src/runtime/work-profile.js")).href)).createWorkProfile; +const cli=resolve("dist/src/cli.js"); +function fixture(){ + const root=mkdtempSync(join(tmpdir(),"of-work-cli-"));const workspace=join(root,"workspace");mkdirSync(workspace,{mode:0o700}); + writeFileSync(join(workspace,"fixture.json"),JSON.stringify({status:"needs_correction",value:0,retained:"yes"}),{mode:0o600}); + writeFileSync(join(workspace,"verify.mjs"),`import {readFileSync} from 'node:fs'; const fixture=JSON.parse(readFileSync(process.argv[2], 'utf8')); const pass=fixture.status === 'corrected' && fixture.value === 1; console.log(pass ? 'fixture verified' : 'fixture failed'); process.exitCode=pass ? 0 : 1;`,{mode:0o600}); + const catalog=createLocalFixtureBindings({workspaceRoot:workspace,fixturePath:"fixture.json",verifierPath:"verify.mjs"}); + const config:WorkProfileConfig={schemaVersion:1,workspaceRoot:workspace,dataDir:join(root,"state"),grantDir:join(root,"authority"),backend:{kind:"local",backendId:"fixture-backend",workspaceId:"fixture-workspace",workItems:[{workItemId:"correction",contract:catalog.contract,plan:{id:"fixture-plan",version:1,state:"active",steps:[catalog.verificationStep]}}]},stepCatalog:{kind:"fixture",fixturePath:"fixture.json",verifierPath:"verify.mjs"},limits:{maxAttempts:1,maxDurationMs:60000,maxActions:10,maxOutputBytes:65536},grantTtlMs:60000}; + const configPath=join(root,"config.json");writeFileSync(configPath,JSON.stringify(config),{mode:0o600}); + const preview=join(root,"preview.json"); + function run(args:string[],input?:string){const result=spawnSync(process.execPath,[cli,...args],{input,encoding:"utf8",timeout:15000});assert.equal(result.error,undefined);return result;} + return {root,workspace,config,configPath,preview,run,dispose:()=>rmSync(root,{recursive:true,force:true})}; +} +function authorization(output:string){const result=output.match(/\{\n "authorized": [\s\S]*\}\s*$/);assert.ok(result,output);return JSON.parse(result[0]) as {authorized:boolean;executionGrantId?:string;completionGrantId?:string;automaticCompletion?:boolean};} + +test("CLI trusted reviewer grants execution, defaults completion off, and can revoke after target loss",async()=>{ + const box=fixture();try{ + const denied=await createWorkProfile(box.configPath);await assert.rejects(denied.controller.start("correction"),/grant|authority/i); + const prepared=box.run(["work","prepare","--config",box.configPath,"--work-item","correction","--out",box.preview]);assert.equal(prepared.status,0,prepared.stderr); + const refused=box.run(["work","authorize","--config",box.configPath,"--preview",box.preview],"NO\n");assert.equal(refused.status,0,refused.stderr);assert.equal(authorization(refused.stdout).authorized,false); + const result=box.run(["work","authorize","--config",box.configPath,"--preview",box.preview],"AUTHORIZE\n\n");assert.equal(result.status,0,result.stderr);const decision=authorization(result.stdout);assert.equal(decision.automaticCompletion,false); + const runtime=denied;const preview=await runtime.controller.prepare("correction");assert.ok(await runtime.grants.resolve(preview.binding,"execution"));assert.equal(await runtime.grants.resolve(preview.binding,"completion"),undefined); + await runtime.controller.start("correction"); + await runtime.controller.shutdown(); + const list=box.run(["work","grants","list","--config",box.configPath]);assert.equal(JSON.parse(list.stdout).length,1); + rmSync(join(box.workspace,"fixture.json")); + const revoke=box.run(["work","grants","revoke","--config",box.configPath,"--grant",decision.executionGrantId!]);assert.equal(revoke.status,0,revoke.stderr); + assert.equal(await runtime.grants.resolve(preview.binding,"execution"),undefined); + }finally{box.dispose();} +}); + +test("CLI separate completion decision permits a scoped correction with fresh evidence",async()=>{ + const box=fixture();try{ + assert.equal(box.run(["work","prepare","--config",box.configPath,"--work-item","correction","--out",box.preview]).status,0); + const granted=box.run(["work","authorize","--config",box.configPath,"--preview",box.preview],"AUTHORIZE\nCOMPLETE\n");assert.equal(granted.status,0,granted.stderr);const decision=authorization(granted.stdout);assert.equal(decision.automaticCompletion,true);assert.notEqual(decision.executionGrantId,decision.completionGrantId); + const runtime=await createWorkProfile(box.configPath); + try{ + await runtime.controller.start("correction"); + const inspected=await runtime.controller.step("inspect",{}); + const expectedDigest=(inspected.result.data as {expectedDigest:string}).expectedDigest; + await runtime.controller.step("correct",{expectedDigest}); + await runtime.controller.verify("fixture-check"); + const finished=await runtime.controller.finish();assert.equal(finished.state,"completed"); + assert.deepEqual(JSON.parse(readFileSync(join(box.workspace,"fixture.json"),"utf8")),{status:"corrected",value:1,retained:"yes"}); + }finally{await runtime.controller.shutdown();} + }finally{box.dispose();} +}); + +test("CLI refuses stale previews and emits Hermes configuration with explicit work allowlist",()=>{ + const box=fixture();try{ + assert.equal(box.run(["work","prepare","--config",box.configPath,"--work-item","correction","--out",box.preview]).status,0); + writeFileSync(box.configPath,JSON.stringify({...box.config,grantTtlMs:120000}));chmodSync(box.configPath,0o600); + const stale=box.run(["work","authorize","--config",box.configPath,"--preview",box.preview],"AUTHORIZE\nCOMPLETE\n");assert.equal(stale.status,1);assert.match(stale.stderr,/Configuration changed/); + const config=box.run(["hermes-config","--config",box.configPath]);assert.equal(config.status,0,config.stderr); + const server=JSON.parse(config.stdout).mcp_servers.openfunctions;assert.equal(server.protocol,"legacy");assert.equal(server.trust,"untrusted");assert.ok(server.tools.include.includes("of_work_step"));assert.equal(server.tools.include.includes("fixture_correct"),false);assert.equal(server.tools.resources,false); + }finally{box.dispose();} +}); diff --git a/test-client/run-work-tests.ts b/test-client/run-work-tests.ts new file mode 100644 index 0000000..162f858 --- /dev/null +++ b/test-client/run-work-tests.ts @@ -0,0 +1,339 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { FileArtifactStore } from "../src/framework/artifacts.js"; +import { ExecutionJournal } from "../src/framework/execution-journal.js"; +import { createWorkTools } from "../src/framework/work/tools.js"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { digestJson } from "../src/framework/session.js"; +import { WorkAttemptController } from "../src/framework/work/controller.js"; +import { LocalGrantStore } from "../src/framework/work/grants.js"; +import { LocalWorkBackend } from "../src/framework/work/local-backend.js"; +import { createLocalFixtureBindings } from "../src/framework/work/local-fixture.js"; +import { JournalConcurrencyError, JournalInvariantError } from "../src/framework/journal-file.js"; +import type { ExecutionJournalSink } from "../src/framework/execution-types.js"; +import type { ResolvedWorkGrant, WorkBackend, VerificationEvidence, WorkControllerOptions } from "../src/framework/work/types.js"; + +async function setup(t:{after(fn:()=>void|Promise):void},options:{journal?:ExecutionJournalSink;completion?:boolean;human?:boolean;maxActions?:number;maxOutputBytes?:number;verificationTimeoutMs?:number;afterEffect?:()=>Promise;verifier?:string;leaseSeconds?:number;backendWrap?:(backend:WorkBackend)=>WorkBackend}={}) { + const root=mkdtempSync(join(tmpdir(),"openfunction-work-"));chmodSync(root,0o700); + let now=Date.now();const clock=()=>now; + const fixture=join(root,"fixture.json");const verifier=join(root,"verify.mjs"); + writeFileSync(fixture,'{"status":"needs_correction","value":0}\n',{mode:0o600}); + writeFileSync(verifier,options.verifier ?? 'import fs from "node:fs";const value=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));if(value.status!=="corrected"||value.value!==1)process.exit(1);console.log("verified");\n',{mode:0o600}); + const bindings=createLocalFixtureBindings({workspaceRoot:root,fixturePath:"fixture.json",verifierPath:"verify.mjs",afterEffect:options.afterEffect}); + if(options.verificationTimeoutMs)bindings.verificationStep.timeoutMs=options.verificationTimeoutMs; + const registry=new ToolRegistry({clock,...(options.journal ? {journal:options.journal}: {})});registry.registerAll(bindings.tools); + const local=new LocalWorkBackend({rootDir:join(root,"backend"),backendId:"local-test",workspaceId:"test-workspace",workItems:[{workItemId:"job",contract:bindings.contract,plan:{id:"plan",version:1,state:"active",steps:[bindings.verificationStep]},humanReviewRequired:options.human}],clock}); + const backend=options.backendWrap?.(local) ?? local;const grants=new LocalGrantStore({rootDir:join(root,"grants"),clock}); + const config:WorkControllerOptions={backend,registry,grantResolver:grants,journalRoot:join(root,"journals"),artifactRoot:join(root,"artifacts"),workspaceRoot:root,claimOwner:"test-owner",steps:bindings.steps,verificationBindings:bindings.verificationBindings,clock,leaseSeconds:options.leaseSeconds ?? 120,renewalSafetyMs:25}; + const controller=new WorkAttemptController(config); + const preview=await controller.prepare("job"); + const limits:ResolvedWorkGrant["limits"]={maxAttempts:1,maxDurationMs:60000,maxActions:options.maxActions ?? 10,maxOutputBytes:options.maxOutputBytes ?? 65536}; + const common={expiresAt:now+60000,actions:bindings.contract.actions,roots:[root],include:["fixture.json"],exclude:[],stepBindings:Object.fromEntries(Object.entries(bindings.steps).map(([key,step])=>[key,{toolName:step.toolName,capabilityDigest:step.capabilityDigest}])),acceptanceMethods:["fixture-readback"],limits}; + const executionGrant=grants.issue(preview.binding,{...common,purpose:"execution"}); + if(options.completion !== false)grants.issue(preview.binding,{...common,purpose:"completion",executionGrantId:executionGrant.id}); + t.after(async()=>{await controller.shutdown();rmSync(root,{recursive:true,force:true});}); + return {root,fixture,verifier,controller,config,backend,local,grants,bindings,executionGrant,preview,advance:(ms:number)=>now+=ms}; +} +async function correct(controller:WorkAttemptController):Promise{const inspected=await controller.step("inspect",{});assert.equal(inspected.result.success,true);const expectedDigest=(inspected.result.data as {expectedDigest:string}).expectedDigest;const changed=await controller.step("correct",{expectedDigest});assert.equal(changed.result.success,true);} + + test("local fixture uses persisted execution/completion grants and fresh independent proof",async t=>{ + const {controller,fixture,backend}=await setup(t);await controller.start("job");await correct(controller);const evidence=await controller.verify("fixture-check");assert.equal(evidence.outcome,"passed");assert.equal(evidence.readbackPassed,true);assert.equal((await controller.finish()).state,"completed");assert.equal((await backend.inspect("job")).state,"completed");assert.equal(JSON.parse(readFileSync(fixture,"utf8")).value,1); + }); + test("execution succeeds without completion authority and routes to review",async t=>{ + const {controller}=await setup(t,{completion:false});await controller.start("job");await correct(controller);await controller.verify("fixture-check");assert.equal((await controller.finish()).state,"needs_review"); + }); + test("passing a no-op verifier cannot replace independent postcondition readback",async t=>{ + const {controller}=await setup(t,{verifier:'console.log("pretend passed");\n'});await controller.start("job");const evidence=await controller.verify("fixture-check");assert.equal(evidence.exitCode,0);assert.equal(evidence.outcome,"failed");assert.equal((await controller.finish()).state,"needs_review"); + }); + test("verify then externally mutate target prevents completion",async t=>{ + const {controller,fixture,backend}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check");writeFileSync(fixture,'{"status":"corrected","value":1,"unverified":true}\n');assert.equal((await controller.finish()).state,"needs_review");assert.equal((await backend.inspect("job")).state,"needs_review"); + }); + test("changed verifier source invalidates prior passing evidence",async t=>{ + const {controller,verifier}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check");writeFileSync(verifier,'console.log("changed source");\n');assert.equal((await controller.finish()).state,"needs_review"); + }); + test("explicit correction after verification invalidates all prior epoch evidence",async t=>{ + const {controller}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check");await correct(controller);const status=await controller.status();assert.deepEqual(status.evidence,[]);assert.equal(status.state,"running");assert.equal((await controller.finish()).state,"needs_review"); + }); + test("revocation stops next admission and terminal authorization",async t=>{ + const {controller,grants,executionGrant}=await setup(t);await controller.start("job");grants.revoke(executionGrant.id);await assert.rejects(()=>controller.step("inspect",{}),/grant missing/);await assert.rejects(()=>controller.finish(),/grant missing/); + }); + test("concurrent requests cannot double-spend action limits",async t=>{ + const {controller}=await setup(t,{maxActions:1});await controller.start("job");const results=await Promise.allSettled([controller.step("inspect",{}),controller.step("inspect",{})]);assert.equal(results.filter(result=>result.status === "fulfilled").length,1);assert.equal((await controller.status()).actions,1); + }); + test("competing controllers cannot adopt a live attempt",async t=>{ + const {controller,config}=await setup(t);await controller.start("job");const other=new WorkAttemptController(config);await assert.rejects(()=>other.reconcile(),JournalConcurrencyError);await other.shutdown(); + }); + test("status is read-only and lease expiration stops admission",async t=>{ + const {controller,advance,backend}=await setup(t,{leaseSeconds:1});await controller.start("job");const before=(await backend.inspect("job")).lease!.expiresAt;await controller.status();assert.equal((await backend.inspect("job")).lease!.expiresAt,before);advance(1100);await assert.rejects(()=>controller.step("inspect",{}),/lease/i); + }); + test("long work effect overlaps authorized renewal without deadlock",async t=>{ + let release!:()=>void;let entered!:()=>void;const entry=new Promise(resolve=>entered=resolve);const barrier=new Promise(resolve=>release=resolve); + const {controller,advance,backend}=await setup(t,{leaseSeconds:1,afterEffect:async()=>{entered();await barrier;}});await controller.start("job");const inspect=await controller.step("inspect",{});const original=(await backend.inspect("job")).lease!.expiresAt; + const effect=controller.step("correct",{expectedDigest:(inspect.result.data as {expectedDigest:string}).expectedDigest});await entry;advance(600);await controller.renew();assert.ok((await backend.inspect("job")).lease!.expiresAt>original);advance(600);release();assert.equal((await effect).result.success,true);await controller.verify("fixture-check");assert.equal((await controller.finish()).state,"completed"); + }); + test("failed renewal stops admissions while retaining a started effect receipt",async t=>{ + let release!:()=>void;let entered!:()=>void;const entry=new Promise(resolve=>entered=resolve);const barrier=new Promise(resolve=>release=resolve); + const {controller}=await setup(t,{afterEffect:async()=>{entered();await barrier;},backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "transition")return async (...args:Parameters)=>{if(args[1] === "heartbeat")throw new Error("renewal lost");return target.transition(...args);};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await controller.start("job");const inspect=await controller.step("inspect",{});const effect=controller.step("correct",{expectedDigest:(inspect.result.data as {expectedDigest:string}).expectedDigest});await entry;await assert.rejects(()=>controller.renew(),/renewal/);await assert.rejects(()=>controller.step("inspect",{}),/not accepting/);release();assert.equal((await effect).result.success,true);assert.equal((await controller.status()).state,"reconciliation_required"); + }); + test("backend identical keys return receipts and conflicting payloads fail",async t=>{ + const {local}=await setup(t);const input={workItemId:"job",claimOwner:"owner",leaseSeconds:120,idempotencyKey:"fixed-open"};const lease=await local.open(input);assert.deepEqual(await local.open(input),lease);await assert.rejects(()=>local.open({...input,claimOwner:"other"}),/Conflicting/);const payload={leaseId:lease.id,action:"start",payload:{}};const receipt=await local.transition(lease,"start",{},"fixed-start");assert.deepEqual(await local.reconcileOperation({id:"fixed-start",backendKey:"fixed-start",kind:"start",payloadDigest:digestJson(payload),capabilityVersion:local.capabilities.version}),{state:"settled",receipt,sourceRefs:["local:local-test:job"]});await assert.rejects(()=>local.transition(lease,"start",{different:true},"fixed-start"),/Conflicting/); + }); + test("lost evidence response reconciles persisted output without rerunning verifier",async t=>{ + let lost=true;let verifications=0; + const {controller,config}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "submitEvidence")return async (...args:Parameters)=>{const receipt=await target.submitEvidence(...args);if(lost){lost=false;throw new Error("lost evidence response");}return receipt;};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + const binding=config.verificationBindings[0]!;const run=binding.run;binding.run=async context=>{verifications++;return run(context);};await controller.start("job");await correct(controller);await assert.rejects(()=>controller.verify("fixture-check"),/lost evidence/);assert.equal((await controller.status()).state,"reconciliation_required");await controller.reconcile();assert.equal(verifications,1);assert.equal((await controller.finish()).state,"completed"); + }); + test("lost terminal response reads keyed receipt and confirms completion",async t=>{ + let lost=true;const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "transition")return async (...args:Parameters)=>{const receipt=await target.transition(...args);if(args[1] === "complete" && lost){lost=false;throw new Error("lost complete response");}return receipt;};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await controller.start("job");await correct(controller);await controller.verify("fixture-check");await assert.rejects(()=>controller.finish(),/lost complete/);assert.equal((await controller.reconcile()).state,"completed"); + }); + + test("authoritative evidence changing under the same ID cannot complete",async t=>{ + let corrupt=false;const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "getVerificationPlan")return async (...args:Parameters)=>{const plan=await target.getVerificationPlan(...args);if(!corrupt)return plan;return {...plan,coverage:Object.fromEntries(Object.entries(plan.coverage).map(([id,evidence])=>[id,{...evidence,outcome:"failed" as const}]))};};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await controller.start("job");await correct(controller);const evidence=await controller.verify("fixture-check");corrupt=true;assert.equal((await controller.finish()).state,"needs_review");assert.equal(evidence.outcome,"passed"); + }); + test("explicit expired local release frees only the unchanged known lease",async t=>{ + const {controller,advance,backend}=await setup(t,{leaseSeconds:1});await controller.start("job");await controller.step("inspect",{});const original=(await backend.inspect("job")).lease!;advance(1100); + assert.equal((await controller.finish("release")).state,"released");assert.equal((await backend.inspect("job")).claimable,true); + const successor=await backend.open({workItemId:"job",claimOwner:"successor",leaseSeconds:10,idempotencyKey:"successor-key"});assert.notEqual(successor.id,original.id); + await assert.rejects(()=>backend.release(original,"old-owner-release"),/does not match/); + }); + test("human acceptance gate cannot be satisfied by passing commands",async t=>{ + const {controller,backend}=await setup(t,{human:true});await controller.start("job");await correct(controller);await controller.verify("fixture-check");assert.equal((await controller.finish()).state,"needs_review");assert.equal((await backend.inspect("job")).state,"needs_review"); + }); + test("changed inputs during verification persist an inconclusive run",async t=>{ + const {controller,config,fixture}=await setup(t);const binding=config.verificationBindings[0]!;const run=binding.run;binding.run=async context=>{const result=await run(context);const current=JSON.parse(readFileSync(fixture,"utf8"));writeFileSync(fixture,JSON.stringify({...current,changedDuringVerification:true}));return result;}; + await controller.start("job");await correct(controller);assert.equal((await controller.verify("fixture-check")).outcome,"inconclusive");assert.equal((await controller.finish()).state,"needs_review"); + }); + test("verification freezes mutation admissions until the command drains",async t=>{ + let entered!:()=>void;let release!:()=>void;const entry=new Promise(resolve=>entered=resolve);const barrier=new Promise(resolve=>release=resolve); + const {controller,config}=await setup(t);const binding=config.verificationBindings[0]!;const run=binding.run;binding.run=async context=>{entered();await barrier;return run(context);};await controller.start("job");await correct(controller);const verification=controller.verify("fixture-check");await entry;await assert.rejects(()=>controller.step("correct",{expectedDigest:"fake"}),/freezes/);release();assert.equal((await verification).outcome,"passed"); + }); + test("registered work steps cannot exceed canonical contract scope",async t=>{ + const {controller,config,grants,preview}=await setup(t);const correctStep=config.steps.correct!; + correctStep.paths=()=>["verify.mjs"]; + await controller.start("job");await assert.rejects(()=>controller.step("correct",{expectedDigest:"fake"}),/outside granted scope/); + }); + test("known result survives restart and is replayed through status",async t=>{ + const {controller,config}=await setup(t);await controller.start("job");await correct(controller);const prior=(await controller.status()).lastStepExecution;await controller.shutdown();const restarted=new WorkAttemptController(config);t.after(()=>restarted.shutdown());assert.deepEqual((await restarted.status()).lastStepExecution,prior);assert.equal((await restarted.reconcile()).state,"running"); + }); + test("model-supplied verification fields are absent from the finite tool surface",async t=>{ + const {controller}=await setup(t);await controller.start("job");await assert.rejects(()=>controller.verify("fake-passed-step"),/unsupported/);await assert.rejects(()=>controller.step("arbitrary_shell",{command:"echo passed"}),/Unregistered/); + }); + + for(const boundary of ["verification_before_requested","step_before_requested","verification_after_start","verification_after_effect","verification_after_result","step_after_result","step_after_effect"]){ + test(`SIGKILL ${boundary} retains receipt/evidence without unsafe replay`,async t=>{ + const verifier='import fs from "node:fs";const count="run-count";fs.writeFileSync(count,String(fs.existsSync(count)?Number(fs.readFileSync(count,"utf8"))+1:1));const value=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));if(value.status!=="corrected"||value.value!==1)process.exit(1);console.log("verified");\n'; + const {controller,config,root}=await setup(t,{verifier});await controller.start("job");if(boundary.startsWith("verification_"))await correct(controller);await controller.shutdown(); + const child=await crashChild(root,boundary);await new Promise(resolve=>{child.once("exit",()=>resolve());child.kill("SIGKILL");}); + const restarted=new WorkAttemptController(config);t.after(()=>restarted.shutdown());const status=await restarted.reconcile(); + if(boundary === "verification_before_requested" || boundary === "step_before_requested") {assert.equal(status.state,"running");if(boundary === "verification_before_requested"){await restarted.verify("fixture-check");assert.equal(readFileSync(join(root,"run-count"),"utf8"),"1");assert.equal((await restarted.finish()).state,"completed");}else assert.equal(JSON.parse(readFileSync(join(root,"fixture.json"),"utf8")).value,0);} + else if(boundary === "verification_after_result") {assert.equal(status.state,"verifying");assert.equal((await restarted.finish()).state,"completed");assert.equal(readFileSync(join(root,"run-count"),"utf8"),"1");} + else if(boundary === "step_after_effect") {assert.equal(status.state,"running");const resolutions=status.effectResolutions as Record;assert.equal(Object.values(resolutions)[0]!.effect,"verified");const unknownBefore=Object.keys(resolutions)[0]!;const events=readdirSync(join(root,"journals","executions")).filter(name=>name.endsWith(".jsonl")).flatMap(name=>readFileSync(join(root,"journals","executions",name),"utf8").trim().split("\n").filter(Boolean).map(line=>JSON.parse(line)));assert.ok(!events.some(event=>event.executionId === unknownBefore && event.type === "settled"));await restarted.verify("fixture-check");assert.equal((await restarted.finish()).state,"completed");} + else if(boundary === "step_after_result") {assert.equal(status.state,"running");assert.equal((status.lastStepExecution as {result:{data:{corrected:boolean}}}).result.data.corrected,true);assert.equal(JSON.parse(readFileSync(join(root,"fixture.json"),"utf8")).value,1);} + else {assert.equal(status.state,"reconciliation_required");const again=await restarted.reconcile();assert.equal(again.state,"reconciliation_required");await assert.rejects(()=>restarted.verify("fixture-check"),/drain/);if(boundary === "verification_after_effect")assert.equal(readFileSync(join(root,"run-count"),"utf8"),"1");} + }); + } + function crashChild(root:string,boundary:string):Promise> { + return new Promise((resolve,reject)=>{ + const child=spawn(process.execPath,["--import","tsx",fileURLToPath(new URL("./fixtures/work-crash-fixture.ts",import.meta.url))],{env:{...process.env,OF_WORK_CRASH_ROOT:root,OF_WORK_CRASH_MODE:boundary},stdio:["ignore","ignore","pipe","ipc"]}); + let error="";child.stderr?.on("data",chunk=>error+=chunk);const timer=setTimeout(()=>{child.kill("SIGKILL");reject(new Error(`Work child timed out: ${error}`));},10000);child.once("message",()=>{clearTimeout(timer);resolve(child);});child.once("error",error=>{clearTimeout(timer);reject(error);});child.once("exit",code=>{clearTimeout(timer);reject(new Error(`Work child failed ${code}: ${error}`));}); + }); + } + + test("mutable caller parameters cannot bypass a registered admission rule",async t=>{ + const {controller,grants,bindings,fixture}=await setup(t);await controller.start("job");const inspected=await controller.step("inspect",{});const actual=(inspected.result.data as {expectedDigest:string}).expectedDigest; + bindings.steps.correct!.validateParams=params=>{if(params.expectedDigest !== "SAFE_ONLY")throw new JournalInvariantError("Forbidden custom parameters");}; + let entered!:()=>void;let release!:()=>void;const entry=new Promise(resolve=>entered=resolve);const barrier=new Promise(resolve=>release=resolve);const reserve=grants.reserveAction.bind(grants); + grants.reserveAction=async (...args)=>{entered();await barrier;return reserve(...args);}; + const params={expectedDigest:"SAFE_ONLY"};const operation=controller.step("correct",params);await entry;params.expectedDigest=actual;release();assert.equal((await operation).result.success,false);assert.equal(JSON.parse(readFileSync(fixture,"utf8")).value,0); + }); + test("foreign lease descriptors never authorize another work transition",async t=>{ + let starts=0;const {controller,backend}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "open")return async (...args:Parameters)=>({...await target.open(...args),workItemId:"foreign-work"});if(key === "transition")return async (...args:Parameters)=>{starts++;return target.transition(...args);};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await assert.rejects(()=>controller.start("job"),/descriptor identity/);assert.equal(starts,0);assert.equal((await controller.status()).state,"reconciliation_required"); + }); + test("receipt with a foreign operation identity is not adopted",async t=>{ + const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "transition")return async (...args:Parameters)=>({...await target.transition(...args),operationId:"foreign-operation"});const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await assert.rejects(()=>controller.start("job"),/receipt operation\/work binding/);assert.equal((await controller.status()).state,"reconciliation_required"); + }); + test("cancelled slow preflight never dispatches a claim",async t=>{ + let pause=false;let opens=0;let entered!:()=>void;let release!:()=>void;const entry=new Promise(resolve=>entered=resolve);const barrier=new Promise(resolve=>release=resolve); + const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "inspect")return async (...args:Parameters)=>{if(pause){entered();await barrier;}return target.inspect(...args);};if(key === "open")return async (...args:Parameters)=>{opens++;return target.open(...args);};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + pause=true;const cancellation=new AbortController();const start=controller.start("job",{signal:cancellation.signal});await entry;cancellation.abort();release();await assert.rejects(()=>start,/cancelled/);assert.equal(opens,0);pause=false;assert.equal((await controller.start("job") as {state:string}).state,"running"); + }); + test("cancellation during terminal proof stops the later complete dispatch",async t=>{ + const {controller,config,backend}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check"); + let entered!:()=>void;let release!:()=>void;const entry=new Promise(resolve=>entered=resolve);const barrier=new Promise(resolve=>release=resolve);const binding=config.verificationBindings[0]!;const readback=binding.readback;binding.readback=async root=>{entered();await barrier;return readback(root);}; + const cancellation=new AbortController();const finishing=controller.finish("complete",{signal:cancellation.signal});await entry;cancellation.abort();release();await assert.rejects(()=>finishing,/cancelled/);assert.equal((await backend.inspect("job")).state,"running");binding.readback=readback;assert.equal((await controller.reconcile()).state,"verifying"); + }); + test("verification subprocess environment does not inherit unrelated credentials",async t=>{ + process.env.OF_VERIFICATION_TEST_SECRET="private-test-sentinel"; + try{const {controller}=await setup(t,{verifier:'if(process.env.OF_VERIFICATION_TEST_SECRET)process.exit(2);console.log("environment bounded");\n'});await controller.start("job");await correct(controller);assert.equal((await controller.verify("fixture-check")).exitCode,0);}finally{delete process.env.OF_VERIFICATION_TEST_SECRET;} + }); + + test("missing mandatory execution journal cannot prove non-dispatch",async t=>{ + const {controller,config,root}=await setup(t);await controller.start("job");await controller.shutdown();const directory=join(root,"journals","executions");const path=join(directory,readdirSync(directory).find(name=>name.endsWith(".jsonl"))!);unlinkSync(path);assert.throws(()=>new WorkAttemptController(config),JournalInvariantError); + }); + test("replaced execution stream identity fails before recovery",async t=>{ + const {controller,config,root}=await setup(t);await controller.start("job");await controller.shutdown();const directory=join(root,"journals","executions");const path=join(directory,readdirSync(directory).find(name=>name.endsWith(".jsonl"))!);const bytes=readFileSync(path);renameSync(path,`${path}.preserved`);writeFileSync(path,bytes,{mode:0o600});assert.throws(()=>new WorkAttemptController(config),/identity changed/); + }); + + test("active plan timeout reaches the shell-free verification subprocess",async t=>{ + const {controller}=await setup(t,{verificationTimeoutMs:40,verifier:'setTimeout(()=>console.log("late success"),1000);\n'});await controller.start("job");await correct(controller);const evidence=await controller.verify("fixture-check");assert.equal(evidence.exitCode,null);assert.equal(evidence.outcome,"inconclusive");assert.equal((await controller.finish()).state,"needs_review"); + }); + test("large output keeps its canonical receipt and exposes a bounded artifact projection",async t=>{ + const {controller,root,fixture}=await setup(t,{maxOutputBytes:262144});writeFileSync(fixture,JSON.stringify({status:"needs_correction",value:0,payload:"x".repeat(40000)}));await controller.start("job"); + const execution=await controller.step("inspect",{});assert.equal(execution.receipt.resultDigest,digestJson(execution.result));assert.ok(execution.artifactProjection); + const status=await controller.status();const store=new FileArtifactStore({rootDir:join(root,"artifacts",status.attemptId as string)});const metadata=store.metadata(execution.artifactProjection!.artifact.id);const chunks:Buffer[]=[];for(let offset=0;offset{ + let opens=0;const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "open")return async (...args:Parameters)=>{opens++;const lease=await target.open(...args);throw new Error("claim response lost");};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await assert.rejects(()=>controller.start("job"),/claim response lost/);assert.equal((await controller.reconcile()).state,"running");assert.equal(opens,1); + }); + test("authoritative not-started claim permits a new authorized operation",async t=>{ + let opens=0;const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "open")return async (...args:Parameters)=>{opens++;if(opens === 1)throw new Error("claim not dispatched");return target.open(...args);};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await assert.rejects(()=>controller.start("job"),/claim not dispatched/);assert.equal((await controller.reconcile()).state,"running");assert.equal(opens,2); + }); + test("known non-dispatch at start resumes the same selected claim",async t=>{ + let starts=0;const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "transition")return async (...args:Parameters)=>{if(args[1] === "start" && ++starts === 1)throw new Error("start not dispatched");return target.transition(...args);};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await assert.rejects(()=>controller.start("job"),/start not dispatched/);assert.equal((await controller.reconcile()).state,"running");assert.equal(starts,2); + }); + test("lost review response reconciles its terminal receipt without work replay",async t=>{ + let reviews=0;const {controller}=await setup(t,{completion:false,backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "transition")return async (...args:Parameters)=>{const receipt=await target.transition(...args);if(args[1] === "review"){reviews++;throw new Error("review response lost");}return receipt;};const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await controller.start("job");await assert.rejects(()=>controller.finish(),/review response lost/);assert.equal((await controller.reconcile()).state,"needs_review");assert.equal(reviews,1); + }); + test("backend uncertainty without authoritative reconciliation remains blocked",async t=>{ + let opens=0;const {controller}=await setup(t,{backendWrap:backend=>new Proxy(backend,{get(target,key){if(key === "capabilities")return {...target.capabilities,keyedOperations:[],receiptLookup:[],missingReceiptProvesNotStarted:false};if(key === "open")return async (...args:Parameters)=>{opens++;await target.open(...args);throw new Error("unresolved open");};if(key === "reconcileOperation")return async()=>({state:"unknown",sourceRefs:["unsupported lookup"]});const value=Reflect.get(target,key);return typeof value === "function" ? value.bind(target) : value;}})}); + await assert.rejects(()=>controller.start("job"),/unresolved open/);assert.equal((await controller.reconcile()).state,"reconciliation_required");assert.equal(opens,1);await assert.rejects(()=>controller.finish(),/uncertain/); + }); + + test("exhausted output remains a durable admission block through reconciliation",async t=>{ + const {controller,fixture}=await setup(t,{maxOutputBytes:100});await controller.start("job");await assert.rejects(()=>controller.step("inspect",{}),/output/);assert.equal((await controller.status()).state,"reconciliation_required");const before=readFileSync(fixture,"utf8"); + await assert.rejects(()=>controller.reconcile(),/output budget/);await assert.rejects(()=>controller.step("correct",{expectedDigest:digestJson(before)}),/not accepting|output budget/);assert.equal(readFileSync(fixture,"utf8"),before);assert.equal((await controller.finish("release")).state,"released"); + }); + + test("exact expected fixture digest reconciles an uncertain effect with separate readback",async t=>{ + const {controller,root,fixture}=await setup(t,{afterEffect:async()=>{throw new Error("effect response lost");}});await controller.start("job");const inspected=await controller.step("inspect",{});const execution=await controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});assert.equal(execution.outcome,"unknown"); + const directory=join(root,"journals","executions");const path=join(directory,readdirSync(directory).find(name=>name.endsWith(".jsonl"))!);const original=readFileSync(path,"utf8").split("\n").filter(line=>line.includes(execution.receipt.executionId));const status=await controller.reconcile();const proof=(status.effectResolutions as Record)[execution.receipt.executionId]!;assert.ok(proof.readbackExecutionId);assert.notEqual(proof.readbackExecutionId,execution.receipt.executionId);assert.deepEqual(readFileSync(path,"utf8").split("\n").filter(line=>line.includes(execution.receipt.executionId)&&JSON.parse(line).executionId === execution.receipt.executionId),original);assert.equal((await controller.status()).unknownExecutions instanceof Array,true);assert.equal(JSON.parse(readFileSync(fixture,"utf8")).value,1);await controller.verify("fixture-check");assert.equal((await controller.finish()).state,"completed"); + }); + test("mismatching effect subject remains unknown and is never rewritten",async t=>{ + const {controller,fixture}=await setup(t,{afterEffect:async()=>{throw new Error("effect response lost");}});await controller.start("job");const inspected=await controller.step("inspect",{});const execution=await controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});writeFileSync(fixture,'{"status":"corrected","value":1,"otherChange":true}');const bytes=readFileSync(fixture,"utf8");const status=await controller.reconcile();assert.equal(status.state,"reconciliation_required");assert.deepEqual(status.unknownExecutions,[execution.receipt.executionId]);assert.equal(readFileSync(fixture,"utf8"),bytes); + }); + test("oracle failure and changed catalog cannot clear original effect uncertainty",async t=>{ + let fail=false;const {controller,bindings}=await setup(t,{afterEffect:async()=>{throw new Error("effect response lost");}});const binding=bindings.steps.correct!;const readback=binding.reconcileEffect!;binding.reconcileEffect=async context=>{if(fail)throw new Error("oracle unavailable");return readback(context);};await controller.start("job");const inspected=await controller.step("inspect",{});await controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});fail=true;assert.equal((await controller.reconcile()).state,"reconciliation_required");fail=false;binding.reconciliationSpecDigest="changed-spec";await assert.rejects(()=>controller.reconcile(),/catalog or authority changed/); + }); + test("readback cannot consume revoked authority or a lost lease",async t=>{ + const {controller,grants,executionGrant,advance}=await setup(t,{leaseSeconds:1,afterEffect:async()=>{throw new Error("effect response lost");}});await controller.start("job");const inspected=await controller.step("inspect",{});await controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});advance(1100);await assert.rejects(()=>controller.reconcile(),/expiry mismatch/);grants.revoke(executionGrant.id);await assert.rejects(()=>controller.reconcile(),/grant missing/); + }); + + test("SIGKILL after effect receipt charges recovered output before another admission",async t=>{ + const {controller,config,root,fixture}=await setup(t,{maxOutputBytes:200});await controller.start("job");await controller.shutdown();const child=await crashChild(root,"step_after_result");await new Promise(resolve=>{child.once("exit",()=>resolve());child.kill("SIGKILL");}); + const restarted=new WorkAttemptController(config);t.after(()=>restarted.shutdown());await assert.rejects(()=>restarted.reconcile(),/output/);const status=await restarted.status();assert.ok((status.outputBytes as number)>200);const before=readFileSync(fixture,"utf8");await assert.rejects(()=>restarted.step("correct",{expectedDigest:digestJson(before)}),/not accepting|output/);assert.equal(readFileSync(fixture,"utf8"),before); + }); + test("SIGKILL after verification receipt charges its artifact before submission or work",async t=>{ + const verifier='import fs from "node:fs";const value=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));if(value.value!==1)process.exit(1);process.stdout.write("x".repeat(201));'; + const {controller,config,root,fixture}=await setup(t,{maxOutputBytes:350,verifier});await controller.start("job");await correct(controller);await controller.shutdown();const child=await crashChild(root,"verification_after_result");await new Promise(resolve=>{child.once("exit",()=>resolve());child.kill("SIGKILL");}); + const restarted=new WorkAttemptController(config);t.after(()=>restarted.shutdown());await assert.rejects(()=>restarted.reconcile(),/output/);assert.ok(((await restarted.status()).outputBytes as number)>350);const before=readFileSync(fixture,"utf8");await assert.rejects(()=>restarted.step("correct",{expectedDigest:digestJson(before)}),/not accepting|output/);assert.equal(readFileSync(fixture,"utf8"),before); + }); + test("SIGKILL after readback receipt charges the old proof before any fresh observation",async t=>{ + const {controller,config,root,fixture}=await setup(t,{maxOutputBytes:450,afterEffect:async()=>{throw new Error("lost");}});await controller.start("job");const inspected=await controller.step("inspect",{});await controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});await controller.shutdown();const child=await crashChild(root,"readback_after_result");await new Promise(resolve=>{child.once("exit",()=>resolve());child.kill("SIGKILL");}); + const restarted=new WorkAttemptController(config);t.after(()=>restarted.shutdown());await assert.rejects(()=>restarted.reconcile(),/output/);assert.ok(((await restarted.status()).outputBytes as number)>450);const before=readFileSync(fixture,"utf8");await assert.rejects(()=>restarted.step("correct",{expectedDigest:digestJson(before)}),/not accepting|output/);assert.equal(readFileSync(fixture,"utf8"),before); + }); + test("SIGKILL after durable grant output does not charge the same execution twice",async t=>{ + const {controller,config,root}=await setup(t,{maxOutputBytes:200});await controller.start("job");await controller.shutdown();const child=await crashChild(root,"output_after_ledger");await new Promise(resolve=>{child.once("exit",()=>resolve());child.kill("SIGKILL");}); + const restarted=new WorkAttemptController(config);t.after(()=>restarted.shutdown());const status=await restarted.reconcile();assert.equal(status.state,"running");assert.ok((status.outputBytes as number)<200); + const events=readdirSync(join(root,"grants")).filter(name=>name.endsWith(".jsonl")).flatMap(name=>readFileSync(join(root,"grants",name),"utf8").trim().split("\n").map(line=>JSON.parse(line))).filter(event=>event.type === "output");assert.equal(events.length,1);assert.ok(events[0].outputExecutionId); + }); + +function reviewBarrier() { + let entered!:()=>void;let release!:()=>void; + const entry=new Promise(resolve=>entered=resolve); + const waiting=new Promise(resolve=>release=resolve); + return {entry,entered,waiting,release}; +} +async function waitForQueuedSteps(controller:WorkAttemptController,count:number) { + const deadline=Date.now()+3000; + while(((await controller.status()).inflightStepIds as string[]).length !== count) { + assert.ok(Date.now()(resolve=>setImmediate(resolve)); + } +} + +test("queued writes stop after the prior effect becomes uncertain",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release());let effects=0; + const {controller,fixture}=await setup(t,{afterEffect:async()=>{effects++;if(effects === 1){barrier.entered();await barrier.waiting;throw new Error("Result lost after effect");}}}); + await controller.start("job");const inspected=await controller.step("inspect",{}); + const first=controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest}); + await barrier.entry; + const second=controller.step("correct",{expectedDigest:digestJson(readFileSync(fixture,"utf8"))}); + await waitForQueuedSteps(controller,2);barrier.release(); + const [unknown,denied]=await Promise.all([first,second]); + assert.equal(unknown.outcome,"unknown");assert.equal(denied.receipt.phase,"not_started");assert.equal(effects,1); + const status=await controller.status();assert.equal(status.state,"reconciliation_required");assert.deepEqual(status.unknownExecutions,[unknown.receipt.executionId]); +}); + +test("queued writes refuse a lease replaced by a successor",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release());let effects=0; + const {controller,config,local}=await setup(t,{afterEffect:async()=>{effects++;}}); + await controller.start("job");const inspected=await controller.step("inspect",{}); + config.registry.register({name:"hold_for_custody_review",description:"Hold the shared executor reservation",inputSchema:{type:"object",properties:{}},handler:async()=>{barrier.entered();await barrier.waiting;return {success:true};}}); + const hold=config.registry.executeDetailed("hold_for_custody_review",{});await barrier.entry; + const pending=controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest}); + await waitForQueuedSteps(controller,1); + await local.release((await local.inspect("job")).lease!,"review-release"); + const successor=await local.open({workItemId:"job",claimOwner:"successor",leaseSeconds:120,idempotencyKey:"review-successor"}); + await local.transition(successor,"start",{},"review-successor-start"); + barrier.release();await hold;const result=await pending; + assert.equal(result.receipt.phase,"not_started");assert.equal(effects,0);assert.equal((await local.inspect("job")).lease!.owner,"successor"); +}); + +for(const change of ["completion","execution","expiry"] as const)test(`terminal dispatch refuses ${change} authority loss during proof readback`,async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release()); + const {controller,bindings,grants,local,advance}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check"); + const binding=bindings.verificationBindings[0]!;const original=binding.readback; + binding.readback=async root=>{barrier.entered();await barrier.waiting;return original(root);}; + const pending=controller.finish("complete");const rejection=assert.rejects(pending,/grant missing|expired|duration/);await barrier.entry; + if(change === "expiry")advance(60001);else grants.revoke(grants.list().find(grant=>grant.purpose === change)!.id); + barrier.release();await rejection; + assert.equal((await local.inspect("job")).state,"running");assert.equal((await controller.status()).state,"reconciliation_required"); +}); + +test("queued writes stop when the prior result cannot be journaled",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release());let effects=0; + const {controller,fixture}=await setup(t,{afterEffect:async()=>{effects++;if(effects === 1){barrier.entered();await barrier.waiting;}}}); + const original=ExecutionJournal.prototype.record;let failed=false; + ExecutionJournal.prototype.record=function(event){ + const receipt=event.data.receipt as {toolName?:string}|undefined; + if(event.type === "settled" && receipt?.toolName === "fixture_correct" && !failed){failed=true;throw new Error("Result journal unavailable");} + return original.call(this,event); + }; + t.after(()=>{ExecutionJournal.prototype.record=original;}); + await controller.start("job");const inspected=await controller.step("inspect",{}); + const first=controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});await barrier.entry; + const second=controller.step("correct",{expectedDigest:digestJson(readFileSync(fixture,"utf8"))});await waitForQueuedSteps(controller,2);barrier.release(); + const [unknown,denied]=await Promise.all([first,second]); + assert.equal(failed,true);assert.equal(unknown.outcome,"unknown");assert.equal(denied.receipt.phase,"not_started");assert.equal(effects,1); + assert.equal((await controller.status()).state,"reconciliation_required"); +}); + +test("an earlier registry journal failure still fences queued controller effects",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release());let effects=0;let failed=false; + const journal:ExecutionJournalSink={record:event=>{ + if(event.type === "settled" && (event.data.receipt as {toolName:string}).toolName === "fixture_correct" && !failed){failed=true;throw new Error("Primary registry journal failed before controller sink");} + }}; + const {controller,fixture}=await setup(t,{journal,afterEffect:async()=>{effects++;if(effects === 1){barrier.entered();await barrier.waiting;}}}); + await controller.start("job");const inspected=await controller.step("inspect",{}); + const first=controller.step("correct",{expectedDigest:(inspected.result.data as {expectedDigest:string}).expectedDigest});await barrier.entry; + const second=controller.step("correct",{expectedDigest:digestJson(readFileSync(fixture,"utf8"))});await waitForQueuedSteps(controller,2);barrier.release(); + const [unknown,denied]=await Promise.all([first,second]); + assert.equal(failed,true);assert.equal(unknown.outcome,"unknown");assert.equal(denied.receipt.phase,"not_started");assert.equal(effects,1); + assert.equal((await controller.status()).state,"reconciliation_required"); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f54bc84 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "test-client", + "gemini-bridge", + "scripts" + ] +} From 8b985c68b48478ef61db6a16e43982bd3f5d2c9f Mon Sep 17 00:00:00 2001 From: Tom Main Date: Tue, 13 Oct 2026 14:23:49 -0400 Subject: [PATCH 2/2] fix: fence work verification and bound host output --- docs/ARCHITECTURE.md | 16 +- package.json | 2 +- .../openclaw.plugin.json | 1 + .../openclaw-execufunction/src/work-tools.ts | 2 + .../tool-contracts.json | 4 + src/framework/server.ts | 13 +- src/framework/work/controller.ts | 56 +++++- src/framework/work/output.ts | 58 ++++++ src/framework/work/tools.ts | 16 +- test-client/fixtures/hermes-host-proof.py | 2 +- test-client/run-mcp-tests.ts | 84 ++++++++ test-client/run-work-output-tests.ts | 185 ++++++++++++++++++ test-client/run-work-tests.ts | 47 +++++ 13 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 src/framework/work/output.ts create mode 100644 test-client/run-work-output-tests.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bf7d820..302b40e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -132,8 +132,20 @@ keeps the attempt blocked; completion still requires fresh verification. Artifacts are immutable, opaque-ID outputs with verified SHA-256 and bounded reads. The work profile uses a 32 KiB model output budget and a 64 MiB artifact -quota. Larger approved output is saved once and exposed by reference; quota -exhaustion is explicit. The framework provides policy and trusted-code scope +quota per artifact store. Admitted work operation replies, including verification, +status and handler failures, are bounded in both canonical UTF-8 JSON and MCP's +pretty text projection. Schema and catalog rejections happen earlier in the shared +executor and use its diagnostic formatting. +Larger replies expose identity/state and evidence summaries plus an immutable +artifact containing the full canonical `ToolResult` JSON. Content digest lookup +reuses the artifact on unchanged status reads and after restart. The shipped work +profile and OpenClaw work facade expose `of_work_artifact_read` for opaque IDs +from the current selected attempt or saved previews. Reads return at most 16 KiB +as base64. Preview artifacts remain readable +after starting an attempt; current attempt receipts and evidence stay complete. +Quota exhaustion reports `artifactProjection.available: false` explicitly while +preserving the known operation's success and execution certainty. The framework +provides policy and trusted-code scope checks, not OS isolation from malicious plugins or programs. Work dispatch refreshes authoritative backend custody after queueing, and diff --git a/package.json b/package.json index 8f98e9c..5a6a0ae 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "test:caller-convergence": "tsx test-client/run-caller-convergence-tests.ts", "test:durability": "tsx test-client/run-durability-tests.ts", "test:session-v2": "tsx test-client/run-session-v2-tests.ts", - "test:work": "tsx test-client/run-work-tests.ts", + "test:work": "tsx test-client/run-work-tests.ts && tsx test-client/run-work-output-tests.ts", "test:mcp-provider": "tsx test-client/run-mcp-provider-tests.ts", "test:execution": "tsx test-client/run-execution-tests.ts", "test:chat-execution": "tsx test-client/run-chat-execution-tests.ts", diff --git a/plugins/openclaw-execufunction/openclaw.plugin.json b/plugins/openclaw-execufunction/openclaw.plugin.json index a41ec5f..0157221 100644 --- a/plugins/openclaw-execufunction/openclaw.plugin.json +++ b/plugins/openclaw-execufunction/openclaw.plugin.json @@ -97,6 +97,7 @@ "note_list", "note_search", "note_update", + "of_work_artifact_read", "of_work_finish", "of_work_prepare", "of_work_reconcile", diff --git a/plugins/openclaw-execufunction/src/work-tools.ts b/plugins/openclaw-execufunction/src/work-tools.ts index df2405a..84adf46 100644 --- a/plugins/openclaw-execufunction/src/work-tools.ts +++ b/plugins/openclaw-execufunction/src/work-tools.ts @@ -16,6 +16,8 @@ export function buildLocalWorkTools(configPath?: string) { verify: async (...args: Parameters) => (await controller()).verify(...args), status: async (...args: Parameters) => (await controller()).status(...args), reconcile: async (...args: Parameters) => (await controller()).reconcile(...args), + projectOutput: async (...args: Parameters) => (await controller()).projectOutput(...args), + readOutputArtifact: async (...args: Parameters) => (await controller()).readOutputArtifact(...args), finish: async (...args: Parameters) => (await controller()).finish(...args), }; return { diff --git a/plugins/openclaw-execufunction/tool-contracts.json b/plugins/openclaw-execufunction/tool-contracts.json index bcd7b77..0e192d4 100644 --- a/plugins/openclaw-execufunction/tool-contracts.json +++ b/plugins/openclaw-execufunction/tool-contracts.json @@ -214,6 +214,10 @@ "name": "note_update", "inputSchemaDigest": "bb5ff84a2f7df431d97b30c6636745e53bd452daaa7481eaf617578218baf370" }, + { + "name": "of_work_artifact_read", + "inputSchemaDigest": "a4286cff4c4606edaa204e4f9cc0a18bc46b6ac32338028cd4e7d5935f5e48b0" + }, { "name": "of_work_finish", "inputSchemaDigest": "365a241c75e446119866ff70ec3799070e582ca6216495733287882da1ddc7a1" diff --git a/src/framework/server.ts b/src/framework/server.ts index 5380318..6d5c57f 100644 --- a/src/framework/server.ts +++ b/src/framework/server.ts @@ -64,6 +64,7 @@ export function createMcpServer( ? AbortSignal.any([extra.signal, resolved.signal]) : extra.signal, }; + const outputSchema = toolRegistry.get(request.params.name)?.outputSchema; const execution = await toolRegistry.executeDetailed( request.params.name, request.params.arguments ?? {}, context, ); @@ -86,11 +87,17 @@ export function createMcpServer( if (result.message) content.push({ type: "text", text: result.message }); // Keep the provider payload exactly aligned with its advertised schema. // The receipt is separate metadata, never a wrapper around structuredContent. + // The executor validates successful schema output against structuredContent + // or data. MCP clients require that validated object in structuredContent. + const structuredContent = result.structuredContent !== undefined + ? result.structuredContent + : outputSchema && result.success && result.data !== null + && typeof result.data === "object" && !Array.isArray(result.data) + ? result.data as Record + : undefined; return { content, - ...(result.structuredContent === undefined ? {} : { - structuredContent: result.structuredContent, - }), + ...(structuredContent === undefined ? {} : { structuredContent }), ...(!result.success ? { isError: true } : {}), _meta: { ...result._meta, diff --git a/src/framework/work/controller.ts b/src/framework/work/controller.ts index 6e86f67..76f9252 100644 --- a/src/framework/work/controller.ts +++ b/src/framework/work/controller.ts @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { join, relative, resolve, isAbsolute } from "node:path"; import { FileArtifactStore } from "../artifacts.js"; +import { projectWorkOutput } from "./output.js"; +import type { ToolResult } from "../types.js"; import { ExecutionJournal } from "../execution-journal.js"; import { capabilityDigest } from "../executor.js"; import { digestJson, snapshotJson } from "../session.js"; @@ -30,7 +32,7 @@ export class WorkAttemptController { private state?:PersistedAttempt;private journal?:WorkAttemptJournal;private executions?:ExecutionJournal; private ownership?:JournalFileLock;private controllerOwnership?:JournalFileLock;private timer?:ReturnType;private stopping=false; private readonly lifetime=new AbortController();private inflight=new Set>(); - private artifacts!:FileArtifactStore;private readonly registry:ToolRegistry; + private artifacts!:FileArtifactStore;private previewArtifacts?:FileArtifactStore;private readonly registry:ToolRegistry; private readonly root:string;private readonly activePath:string; constructor(private readonly options:WorkControllerOptions){ this.root=initializePrivateRoot(options.journalRoot);this.activePath=join(this.root,"active-attempt.json"); @@ -44,6 +46,26 @@ export class WorkAttemptController { if(last){this.state=structuredClone(last.data.state) as PersistedAttempt;this.validateRecovered();}else throw new JournalInvariantError("Active attempt journal has no prepared event"); } } + /** Bound the facade reply without changing durable work/evidence or certainty. */ + projectOutput(result:ToolResult):ToolResult { + return projectWorkOutput(result,()=>this.state ? this.artifacts : this.previewArtifactStore()); + } + private previewArtifactStore():FileArtifactStore { + return this.previewArtifacts ??= this.options.artifactStore ?? new FileArtifactStore({rootDir:join(this.options.artifactRoot,"previews")}); + } + /** Opaque IDs select current-attempt or preview outputs; prior attempts are not searched. */ + readOutputArtifact(artifactId:string,options:{offset?:number;length?:number}={}):Record { + if(typeof artifactId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(artifactId))throw new JournalInvariantError("Invalid opaque artifact id"); + const stores=[...(this.state ? [this.artifacts] : []),this.previewArtifactStore()]; + let selected:FileArtifactStore|undefined; + for(const store of stores) {if(existsSync(join(store.rootDir,`${artifactId}.artifact`))){store.metadata(artifactId);selected=store;break;}} + if(!selected)throw new JournalInvariantError("Output artifact is unavailable in this work profile"); + const artifact=selected.metadata(artifactId);const offset=options.offset ?? 0; + const length=options.length ?? Math.min(16384,selected.maxReadBytes,Math.max(0,artifact.sizeBytes-offset)); + if(length>16384)throw new JournalInvariantError("Artifact read exceeds work output bounds"); + const bytes=selected.read(artifactId,{offset,length}); + return {artifact,offset,length,encoding:"base64",data:bytes.toString("base64"),hasMore:offset+lengthstep.id === stepId)!.timeoutMs,grant.expiresAt,state.startedAt+grant.limits.maxDurationMs); }); const privateRegistry=this.options.registry.project({names:[]},{authorize:async()=>{ - try{const grant=await this.grant();if(this.stopping || state.renewalFailed || !grant.actions.includes("test") || !state.lease || state.lease.expiresAt <= this.now())return {decision:"deny",reasonCodes:["verification_authority_unavailable"]};return {decision:"allow",authorityGrantRefs:[grant.id],expiresAt:grant.expiresAt};}catch{return {decision:"deny",reasonCodes:["verification_grant_unavailable"]};} + try{const grant=await this.grant();await this.fresh(["running"]);if(this.stopping || !["running","verifying"].includes(state.state) || state.unknownExecutions.length || state.renewalFailed || !grant.actions.includes("test") || !state.lease || state.lease.expiresAt <= this.now())return {decision:"deny",reasonCodes:["verification_authority_unavailable"]};return {decision:"allow",authorityGrantRefs:[grant.id],expiresAt:grant.expiresAt};}catch{return {decision:"deny",reasonCodes:["verification_grant_unavailable"]};} }}); const tool={name:`_of_verify_${stepId.replace(/[^a-zA-Z0-9_]/g,"_")}`,description:"Run the exact configured active verification binding",inputSchema:{type:"object" as const,properties:{},additionalProperties:false},contract:{version:"1",useWhen:[],doNotUseWhen:[],sideEffects:["Execute separately authorized verification command"],commitmentClass:"C1" as const,idempotency:"non_idempotent" as const,requiresAuthority:true,mutation:true,bounds:{specDigest:binding.specDigest,environment:binding.environment}},handler:async(_params:Record,boundContext?:ToolExecutionContext)=>{ const before=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch);const startedAt=this.now(); const result=await binding.run({workspaceRoot:this.options.workspaceRoot,signal:boundContext?.signal ?? (context.signal ? AbortSignal.any([context.signal,this.lifetime.signal]) : this.lifetime.signal)}); - const after=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch);const readbackPassed=result.readbackPassed && await binding.readback(this.options.workspaceRoot); + const readbackPassed=result.readbackPassed && await binding.readback(this.options.workspaceRoot); + const after=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch); const evidence:VerificationEvidence={id:evidenceId,backendId:state.binding.backendId,workItemId:state.workItemId,attemptId:state.attemptId,planId:plan.id,planVersion:plan.version,stepId,specDigest:binding.specDigest,environment:binding.environment,...(result.environmentSnapshot ? {environmentSnapshot:result.environmentSnapshot}:{}),before,after,startedAt,finishedAt:this.now(),exitCode:result.exitCode,outcome:before.digest !== after.digest || before.mutationEpoch !== after.mutationEpoch || result.exitCode === null ? "inconclusive" : result.exitCode === 0 && readbackPassed ? "passed" : "failed",readbackPassed,acceptanceMethods:result.acceptanceMethods,artifact:this.artifacts.put(result.output,{mediaType:"text/plain"})}; return {success:true,data:evidence}; }}; @@ -413,22 +436,35 @@ export class WorkAttemptController { for(const [id,pending] of Object.entries(state.inflightSteps))if(pending === intent || pending.stepKey === intent.stepKey && pending.inputDigest === intent.inputDigest && pending.reconciliationMetadata === intent.reconciliationMetadata)delete state.inflightSteps[id]; this.persist("reconciled",{originalExecutionId,readbackExecutionId:execution.receipt.executionId,disposition:"effect_independently_verified"},{executionId:execution.receipt.executionId,receiptRef:execution.receipt.executionId}); } - private async checkProof():Promise { + private assertProofSubjects(subjects:readonly {binding:WorkControllerOptions["verificationBindings"][number];evidence:VerificationEvidence}[]):void { + const state=this.requireState(); + for(const {binding,evidence} of subjects) { + const current=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch); + if(current.digest !== evidence.after.digest || current.mutationEpoch !== evidence.after.mutationEpoch)throw new JournalInvariantError("Verification subject changed"); + if(digestJson(this.artifacts.metadata(evidence.artifact.id)) !== digestJson(evidence.artifact))throw new JournalInvariantError("Evidence artifact metadata changed"); + } + } + private async checkProof():Promise<()=>void> { const state=this.requireState();const grant=await this.grant("completion");const {snapshot,plan}=await this.fresh(); if(snapshot.humanReviewRequired || !plan.steps.some(step=>step.required) || Object.keys(state.inflightSteps).length || Object.keys(state.verificationRuns).length || state.unknownExecutions.length || Object.values(state.operations).some(operation=>operation.status === "started" && operation.ref.kind !== "complete"))throw new JournalInvariantError("Review, missing proof or uncertain operations prevent completion"); const covered=new Set(); + const subjects:{binding:WorkControllerOptions["verificationBindings"][number];evidence:VerificationEvidence}[]=[]; const criterionMethods=new Set(snapshot.contract.acceptanceCriteria.map(criterion=>criterion.method)); for(const step of plan.steps.filter(step=>step.required || state.evidence[step.id]?.acceptanceMethods.some(method=>criterionMethods.has(method)))) { const binding=this.options.verificationBindings.find(binding=>binding.stepId === step.id && binding.specDigest === step.specDigest && binding.environment === step.environment); const evidence=state.evidence[step.id];const authoritative=plan.coverage[step.id]; if(!binding || !evidence || authoritative?.id !== evidence.id || authoritative.outcome !== "passed" || digestJson(authoritative) !== digestJson(evidence) || evidence.outcome !== "passed" || evidence.before.mutationEpoch !== state.mutationEpoch || evidence.after.mutationEpoch !== state.mutationEpoch || evidence.before.digest !== evidence.after.digest || evidence.specDigest !== step.specDigest || evidence.environment !== step.environment || evidence.planId !== plan.id || evidence.planVersion !== plan.version || evidence.attemptId !== state.attemptId)throw new JournalInvariantError("Current active proof missing"); - const current=fingerprintSubject(this.options.workspaceRoot,state.binding.workspaceId,binding.inputs,state.mutationEpoch); - const artifact=this.artifacts.metadata(evidence.artifact.id); - if(digestJson(artifact) !== digestJson(evidence.artifact))throw new JournalInvariantError("Evidence artifact metadata changed"); - if(current.digest !== evidence.after.digest || !await binding.readback(this.options.workspaceRoot))throw new JournalInvariantError("Verification subject changed or independent readback failed"); + this.assertProofSubjects([{binding,evidence}]); + if(!await binding.readback(this.options.workspaceRoot))throw new JournalInvariantError("Independent readback failed"); + subjects.push({binding,evidence}); for(const method of evidence.acceptanceMethods)covered.add(method); } if(!snapshot.contract.acceptanceCriteria.length || !snapshot.contract.acceptanceCriteria.every(criterion=>grant.acceptanceMethods.includes(criterion.method) && covered.has(criterion.method)))throw new JournalInvariantError("Acceptance method coverage missing"); + // Later readbacks can change inputs checked earlier in the same proof pass. + // Return a synchronous fence so terminal dispatch can repeat this check after + // every awaited authority lookup without collecting another stale snapshot. + this.assertProofSubjects(subjects); + return ()=>this.assertProofSubjects(subjects); } async finish(disposition:"complete"|"review"|"block"|"release"="complete",context:ToolExecutionContext={}):Promise> { return this.admissions.run(async()=>this.lifecycle.run(async()=>{ @@ -446,7 +482,8 @@ export class WorkAttemptController { state.state="released";this.persist("blocked",{disposition:"released",backendReceiptRef:receipt.id});this.releaseOwnership();return this.safeStatus(); } let reason="Explicit terminal disposition"; - if(disposition === "complete")try{await this.checkProof();}catch(error){disposition="review";reason=error instanceof Error ? error.message : "Completion proof unavailable";} + let proofSubjects:(()=>void)|undefined; + if(disposition === "complete")try{proofSubjects=await this.checkProof();}catch(error){disposition="review";reason=error instanceof Error ? error.message : "Completion proof unavailable";} const action=disposition === "complete" ? "complete" : disposition === "review" ? "review" : "block"; const terminalPayload=action === "complete" ? {evidenceIds:Object.values(state.evidence).map(evidence=>evidence.id),mutationEpoch:state.mutationEpoch} : {reason}; const payload={leaseId:state.lease!.id,action,payload:terminalPayload}; @@ -456,6 +493,7 @@ export class WorkAttemptController { await this.grant("execution"); if(action === "complete")await this.grant("completion"); this.assertAdmissionContext(context); + if(action === "complete")proofSubjects!(); return this.options.backend.transition(state.lease!,action,payload.payload,ref.backendKey ?? ref.id,this.requestSignal(context)); },undefined,undefined,context); const snapshot=await this.options.backend.inspect(state.workItemId);const expected=action === "complete" ? "completed" : action === "review" ? "needs_review" : "blocked"; diff --git a/src/framework/work/output.ts b/src/framework/work/output.ts new file mode 100644 index 0000000..7bb91cd --- /dev/null +++ b/src/framework/work/output.ts @@ -0,0 +1,58 @@ +/** Model projections are bounded independently of durable execution and evidence. */ +import { createHash } from "node:crypto"; +import { readdirSync } from "node:fs"; +import { ArtifactQuotaError, DEFAULT_MODEL_OUTPUT_BYTES, type FileArtifactStore } from "../artifacts.js"; +import type { ToolResult } from "../types.js"; + +const scalarKeys = ["id", "attemptId", "workItemId", "backendId", "planId", "planVersion", "stepId", "state", "backendState", "outcome", "executionId", "executionOutcome", "mutationEpoch", "actions", "outputBytes", "active", "exitCode", "readbackPassed", "specDigest", "digest", "environment", "startedAt", "finishedAt", "expiresAt", "sha256", "mediaType", "sizeBytes", "scopeDigest", "contractDigest", "revision", "planDigest"]; +function summary(value: unknown, depth = 0): Record { + if (depth > 2 || !value || typeof value !== "object") return {}; + const source = value as Record; + const result: Record = {}; + for (const key of scalarKeys) { + const item = source[key]; + if (typeof item === "string") result[key] = item.slice(0, 512); + else if (typeof item === "number" || typeof item === "boolean") result[key] = item; + } + for (const key of ["before", "after"]) { + const subject = source[key] as { digest?: string; mutationEpoch?: number; inputManifest?: unknown[] } | undefined; + if (subject) result[key] = { digest: subject.digest?.slice(0, 512), mutationEpoch: subject.mutationEpoch, inputCount: subject.inputManifest?.length }; + } + for (const key of ["binding", "snapshot", "plan", "receipt", "lease", "artifact"]) { + if (source[key]) result[key] = summary(source[key], depth + 1); + } + if (Array.isArray(source.evidence)) result.evidence = { count: source.evidence.length, items: source.evidence.slice(0, 8).map(item => summary(item, depth + 1)) }; + for (const key of ["unknownExecutions", "unresolvedOperations", "inflightStepIds", "outputArtifactRefs", "steps"]) { + if (Array.isArray(source[key])) result[`${key}Count`] = source[key].length; + else if (source[key] && typeof source[key] === "object") result[`${key}Count`] = Object.keys(source[key]).length; + } + return result; +} +function fits(result: ToolResult): boolean { + return Buffer.byteLength(JSON.stringify(result)) <= DEFAULT_MODEL_OUTPUT_BYTES && Buffer.byteLength(JSON.stringify(result, null, 2)) <= DEFAULT_MODEL_OUTPUT_BYTES; +} +export function projectWorkOutput(result: ToolResult, artifactStore?: FileArtifactStore | (() => FileArtifactStore)): ToolResult { + if (fits(result)) return result; + const canonical = JSON.stringify(result); + const digest = createHash("sha256").update(canonical).digest("hex"); + let projection: Record; + try { + const store = typeof artifactStore === "function" ? artifactStore() : artifactStore; + if (!store) throw new Error("Output artifact storage is unavailable"); + // Content identity makes repeated reads reuse a durable artifact, including after restart. + const saved = readdirSync(store.rootDir).filter(name => name.endsWith(".artifact")).map(name => store.metadata(name.slice(0, -9))).find(ref => ref.sha256 === digest && ref.mediaType === "application/json"); + const artifact = saved ?? store.put(canonical, { mediaType: "application/json" }); + projection = { artifact, encoding: "utf-8", format: "canonical ToolResult JSON", readTool: "of_work_artifact_read" }; + } catch (error) { + projection = { available: false, sha256: digest, sizeBytes: Buffer.byteLength(canonical), reason: error instanceof ArtifactQuotaError ? "Artifact quota exhausted" : "Output artifact storage is unavailable" }; + } + const projected: ToolResult = { + success: result.success, + ...(result.executionOutcome ? { executionOutcome: result.executionOutcome } : {}), + ...(result.error ? { error: result.error.slice(0, 512) } : {}), + data: { ...summary(result.data), outputTruncated: true, artifactProjection: projection }, + }; + // Even unusual nesting/counts cannot escape the output budget. + if (!fits(projected)) projected.data = { outputTruncated: true, artifactProjection: projection }; + return projected; +} diff --git a/src/framework/work/tools.ts b/src/framework/work/tools.ts index a82245c..748cdf3 100644 --- a/src/framework/work/tools.ts +++ b/src/framework/work/tools.ts @@ -1,16 +1,22 @@ -import type { ToolDefinition } from "../types.js"; +import { uncertainToolExecution } from "../tool.js"; +import { projectWorkOutput } from "./output.js"; +import type { ToolDefinition, ToolResult } from "../types.js"; import type { ToolExecutionContext } from "../execution-types.js"; import type { ToolRegistry } from "../registry.js"; import type { WorkAttemptController } from "./controller.js"; /** Opt-in finite work facade. Inputs carry selections, never grant/lease custody. */ -export function createWorkTools(controller:Pick, registry?:ToolRegistry):ToolDefinition[] { +export function createWorkTools(controller:Pick & {projectOutput?:(result:ToolResult)=>ToolResult|Promise;readOutputArtifact?:(artifactId:string,options?:{offset?:number;length?:number})=>Record|Promise>}, registry?:ToolRegistry):ToolDefinition[] { const definition=(name:string,description:string,properties:ToolDefinition["inputSchema"]["properties"],required:string[],handler:(params:Record,context?:ToolExecutionContext)=>Promise,readOnly=false):ToolDefinition=>({ name,description,inputSchema:{type:"object",properties,required,additionalProperties:false},annotations:{readOnlyHint:readOnly,destructiveHint:false,openWorldHint:false},executionMode:readOnly ? "parallel-safe" : "exclusive",replayPolicy:readOnly ? "read_only" : "never",handler:async (params,context)=>{ const invoke=async()=>{const canonical=await handler(params,context);const data=canonical && typeof canonical === "object" && "artifactProjection" in canonical && "receipt" in canonical && "outcome" in canonical ? {receipt:(canonical as {receipt:unknown}).receipt,outcome:(canonical as {outcome:unknown}).outcome,artifactProjection:(canonical as {artifactProjection:unknown}).artifactProjection} : canonical;if(data && typeof data === "object" && "receipt" in data && "outcome" in data && (data as {outcome:string}).outcome === "unknown")return {success:false,error:"Work execution is uncertain; reconcile before retry",executionOutcome:"unknown" as const,data};if(data && typeof data === "object" && "receipt" in data && "outcome" in data && (data as {outcome:string}).outcome === "failed")return {success:false,error:"Work step failed; inspect its bound execution receipt",executionOutcome:"failed" as const,data};return {success:true,data};}; - return registry ? registry.withChildExecution(invoke) : invoke(); + let result:ToolResult; + try {result=await (registry ? registry.withChildExecution(invoke) : invoke());} + catch(error){result=uncertainToolExecution(error).result;} + try {return projectWorkOutput(controller.projectOutput ? await controller.projectOutput(result) : result);} + catch {return projectWorkOutput(result);} } }); - return [ + const tools = [ definition("of_work_prepare","Inspect one selected work item and its bound authorization preview",{workItemId:{type:"string",minLength:1}},["workItemId"],(p,context)=>controller.prepare(p.workItemId as string,context),true), definition("of_work_start","Start the explicitly selected bounded work item using current persisted grants",{workItemId:{type:"string",minLength:1}},["workItemId"],(p,context)=>controller.start(p.workItemId as string,context)), definition("of_work_step","Run a finite granted work step",{stepKey:{type:"string",minLength:1},params:{type:"object",additionalProperties:true}},["stepKey","params"],(p,context)=>controller.step(p.stepKey as string,p.params as Record,context)), @@ -19,4 +25,6 @@ export function createWorkTools(controller:Pickcontroller.reconcile(context)), definition("of_work_finish","Finalize only with current authority, subject proof and independent readback",{disposition:{type:"string",enum:["complete","review","block","release"]}},[],(p,context)=>controller.finish((p.disposition ?? "complete") as "complete"|"review"|"block"|"release",context)), ]; + if(controller.readOutputArtifact)tools.push(definition("of_work_artifact_read","Read bounded immutable output from the current work attempt or saved preview",{artifactId:{type:"string",pattern:"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"},offset:{type:"integer",minimum:0},length:{type:"integer",minimum:0,maximum:16384}},["artifactId"],async p=>controller.readOutputArtifact!(p.artifactId as string,{offset:p.offset as number|undefined,length:p.length as number|undefined}),true)); + return tools; } diff --git a/test-client/fixtures/hermes-host-proof.py b/test-client/fixtures/hermes-host-proof.py index e0a5991..211dc61 100644 --- a/test-client/fixtures/hermes-host-proof.py +++ b/test-client/fixtures/hermes-host-proof.py @@ -146,7 +146,7 @@ def work_proof(servers): save_config(native) names = register_mcp_servers(servers) prefix = "mcp__openfunctions__" - assert set(names) == {prefix + name for name in ["of_work_prepare", "of_work_start", "of_work_step", "of_work_verify", "of_work_status", "of_work_reconcile", "of_work_finish"]}, names + assert set(names) == {prefix + name for name in ["of_work_prepare", "of_work_start", "of_work_step", "of_work_verify", "of_work_status", "of_work_reconcile", "of_work_finish", "of_work_artifact_read"]}, names preview = model_tool(prefix + "of_work_prepare", {"workItemId": "correction"}) assert preview["binding"]["workItemId"] == "correction" grants = authorize_job(config_path, "correction") diff --git a/test-client/run-mcp-tests.ts b/test-client/run-mcp-tests.ts index 98a8efa..35bc90a 100644 --- a/test-client/run-mcp-tests.ts +++ b/test-client/run-mcp-tests.ts @@ -8,6 +8,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { ToolRegistry } from "../src/framework/registry.js"; import { createMcpServer, startServer } from "../src/framework/server.js"; +import type { ToolExecution } from "../src/framework/execution-types.js"; import { TOOLS } from "@siftable/mcp-server"; import { createSiftableSdkTools } from "../src/providers/execufunction/sdk-tools.js"; import type { ExfClient } from "../src/providers/execufunction/client.js"; @@ -128,6 +129,89 @@ if (process.argv.includes("--mcp-fixture")) { } }); + test("MCP projects validated data without repeating effects or changing canonical receipts", async () => { + const executions: Readonly[] = []; + const registry = new ToolRegistry({ + journal: { record: () => {}, onSettled: execution => { executions.push(execution); } }, + }); + let effects = 0; + const outputSchema = { + type: "object" as const, + properties: { value: { type: "number" as const } }, + required: ["value"], additionalProperties: false, + }; + registry.register({ + name: "data_effect", description: "Return ordinary data after one effect", inputSchema: emptySchema, + outputSchema, + handler: async () => { + effects++; + return { success: true, data: { value: 42 } }; + }, + }); + registry.register({ + name: "explicit_result", description: "Keep explicit structured output and rich content", inputSchema: emptySchema, + outputSchema, + handler: async () => ({ success: true, data: { value: "legacy data" }, + structuredContent: { value: 7 }, + content: [{ type: "resource_link", name: "Evidence", uri: "file:///evidence.json" }], + _meta: { upstream: "kept", "openfunction/execution": "untrusted receipt" }, + }), + }); + registry.register({ + name: "invalid_result", description: "Reject invalid output after an effect", inputSchema: emptySchema, + outputSchema, + handler: async () => { + effects++; + return { success: true, data: { value: "invalid" } }; + }, + }); + registry.register({ + name: "failed_result", description: "Return a known failure with schema-shaped data", inputSchema: emptySchema, + outputSchema, + handler: async () => ({ success: false, error: "known failure", data: { value: 42 } }), + }); + const handle = createMcpServer(registry, { name: "output-projection" }); + const client = new Client({ name: "output-validation-client", version: "1" }); + const [left, right] = InMemoryTransport.createLinkedPair(); + try { + await handle.connect(right); + await client.connect(left); + const listing = await client.listTools(); // Prime the SDK's output-schema validators. + assert.deepEqual(listing.tools.find(tool => tool.name === "data_effect")?.outputSchema, outputSchema); + const valid = await client.callTool({ name: "data_effect", arguments: {} }); + assert.equal(effects, 1); + assert.equal(valid.isError, undefined); + assert.deepEqual(valid.structuredContent, { value: 42 }); + assert.deepEqual(executions[0].result.data, valid.structuredContent); + assert.equal(executions[0].result.structuredContent, undefined, "Projection does not change the canonical result"); + assert.deepEqual(valid._meta?.["openfunction/execution"], executions[0].receipt); + + const explicit = await client.callTool({ name: "explicit_result", arguments: {} }); + assert.deepEqual(explicit.structuredContent, { value: 7 }); + assert.deepEqual(explicit.content, executions[1].result.content); + assert.equal(explicit._meta?.upstream, "kept"); + assert.deepEqual(explicit._meta?.["openfunction/execution"], executions[1].receipt); + + const invalid = await client.callTool({ name: "invalid_result", arguments: {} }); + assert.equal(effects, 2); + assert.equal(invalid.isError, true); + assert.equal(invalid.structuredContent, undefined); + assert.equal(executions[2].receipt.errorCategory, "invalid_output"); + assert.equal(executions[2].receipt.executionOutcome, "unknown"); + assert.deepEqual(invalid._meta?.["openfunction/execution"], executions[2].receipt); + + const failed = await client.callTool({ name: "failed_result", arguments: {} }); + assert.equal(failed.isError, true); + assert.equal(failed.structuredContent, undefined); + assert.equal(executions[3].receipt.executionOutcome, "failed"); + assert.deepEqual(failed._meta?.["openfunction/execution"], executions[3].receipt); + assert.equal(effects, 2); + } finally { + await client.close(); + await handle.close(); + } + }); + test("MCP lifecycle binds trusted context and signals without trusting arguments", async () => { const registry = new ToolRegistry(); let received: any; diff --git a/test-client/run-work-output-tests.ts b/test-client/run-work-output-tests.ts new file mode 100644 index 0000000..a42ebd1 --- /dev/null +++ b/test-client/run-work-output-tests.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { FileArtifactStore } from "../src/framework/artifacts.js"; +import { ToolRegistry } from "../src/framework/registry.js"; +import { createMcpServer } from "../src/framework/server.js"; +import { digestJson } from "../src/framework/session.js"; +import { WorkAttemptController } from "../src/framework/work/controller.js"; +import { createWorkTools } from "../src/framework/work/tools.js"; +import { createLocalFixtureBindings } from "../src/framework/work/local-fixture.js"; +import { LocalGrantStore } from "../src/framework/work/grants.js"; +import { LocalWorkBackend } from "../src/framework/work/local-backend.js"; +import type { ToolResult } from "../src/framework/types.js"; +import type { WorkControllerOptions } from "../src/framework/work/types.js"; + +async function setup(t: { after(fn: () => void | Promise): void }, options: { large?: boolean; quota?: number } = {}) { + const root = mkdtempSync(join(tmpdir(), "openfunction-output-")); chmodSync(root, 0o700); + writeFileSync(join(root, "fixture.json"), '{"status":"needs_correction","value":0}\n', { mode: 0o600 }); + writeFileSync(join(root, "verify.mjs"), 'console.log("verified");\n', { mode: 0o600 }); + const bindings = createLocalFixtureBindings({ workspaceRoot: root, fixturePath: "fixture.json", verifierPath: "verify.mjs" }); + if (options.large) { + mkdirSync(join(root, "inputs")); + const names = Array.from({ length: 450 }, (_, i) => `inputs/source-${String(i).padStart(4, "0")}-${"x".repeat(50)}.txt`); + for (const name of names) writeFileSync(join(root, name), "sample"); + const binding = bindings.verificationBindings[0]!; + binding.inputs = [...binding.inputs, "inputs"]; + binding.specDigest = digestJson({ priorSpec: binding.specDigest, inputs: binding.inputs }); + bindings.verificationStep.specDigest = binding.specDigest; + bindings.contract.include.push(...names); + } + const registry = new ToolRegistry(); registry.registerAll(bindings.tools); + const backend = new LocalWorkBackend({ rootDir: join(root, "backend"), backendId: "output-test", workspaceId: "test", workItems: [{ workItemId: "job", contract: bindings.contract, plan: { id: "plan", version: 1, state: "active", steps: [bindings.verificationStep] } }] }); + const grants = new LocalGrantStore({ rootDir: join(root, "grants") }); + const config: WorkControllerOptions = { backend, registry, grantResolver: grants, journalRoot: join(root, "journals"), artifactRoot: join(root, "artifacts"), workspaceRoot: root, claimOwner: "output-test", steps: bindings.steps, verificationBindings: bindings.verificationBindings, ...(options.quota === undefined ? {} : { artifactStore: new FileArtifactStore({ rootDir: join(root, "limited-artifacts"), quotaBytes: options.quota }) }) }; + const controller = new WorkAttemptController(config); + const preview = await controller.prepare("job"); + const common = { expiresAt: Date.now() + 60000, actions: bindings.contract.actions, roots: [root], include: ["fixture.json"], exclude: [], stepBindings: Object.fromEntries(Object.entries(bindings.steps).map(([key, step]) => [key, { toolName: step.toolName, capabilityDigest: step.capabilityDigest }])), acceptanceMethods: ["fixture-readback"], limits: { maxAttempts: 1, maxDurationMs: 60000, maxActions: 20, maxOutputBytes: 1048576 } }; + const executionGrant = grants.issue(preview.binding, { ...common, purpose: "execution" }); + grants.issue(preview.binding, { ...common, purpose: "completion", executionGrantId: executionGrant.id }); + const facade = new ToolRegistry(); facade.registerAll(createWorkTools(controller)); + t.after(async () => { await controller.shutdown(); rmSync(root, { recursive: true, force: true }); }); + return { root, config, controller, facade, backend }; +} +function bounded(result: ToolResult): void { + assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 32768); + assert.ok(Buffer.byteLength(JSON.stringify(result, null, 2)) <= 32768); +} +function artifactId(result: ToolResult): string { return (result.data as { artifactProjection: { artifact: { id: string } } }).artifactProjection.artifact.id; } +async function readCanonical(facade: ToolRegistry, id: string): Promise { + const chunks: Buffer[] = []; let offset = 0; + for (;;) { + const reply = await facade.execute("of_work_artifact_read", { artifactId: id, offset }); bounded(reply); assert.equal(reply.success, true); + const data = reply.data as { data: string; length: number; hasMore: boolean }; + chunks.push(Buffer.from(data.data, "base64")); offset += data.length; + if (!data.hasMore) break; + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as ToolResult; +} +async function correct(controller: WorkAttemptController): Promise { + const inspected = await controller.step("inspect", {}); + await controller.step("correct", { expectedDigest: (inspected.result.data as { expectedDigest: string }).expectedDigest }); +} + +test("small facade replies retain their original shape and legacy controller stubs still work", async t => { + const { controller, facade, root } = await setup(t); + assert.deepEqual(await facade.execute("of_work_status", {}), { success: true, data: { state: "prepared", active: false } }); + assert.equal(existsSync(join(root, "artifacts")), false); + await controller.start("job"); await correct(controller); + const evidence = await controller.verify("fixture-check"); + const status = await facade.execute("of_work_status", {}); bounded(status); + assert.deepEqual(status, { success: true, data: await controller.status() }); + assert.deepEqual((status.data as { evidence: unknown[] }).evidence, [evidence]); + const stub = Object.fromEntries(["prepare", "start", "step", "verify", "status", "reconcile", "finish"].map(name => [name, async () => ({ state: "running" })])) as unknown as Parameters[0]; + assert.equal(createWorkTools(stub).length, 7); + assert.deepEqual(await createWorkTools(stub)[0]!.handler({ workItemId: "job" }), { success: true, data: { state: "running" } }); +}); + +test("large preview, evidence, status, reconciliation and finish replies retain full readable canonical artifacts", async t => { + const { controller, facade, config } = await setup(t, { large: true }); + const preview = await facade.execute("of_work_prepare", { workItemId: "job" }); bounded(preview); + assert.equal((await readCanonical(facade, artifactId(preview))).success, true); + await controller.start("job"); await correct(controller); + // Preview artifact IDs remain reachable after attempt storage is selected. + assert.equal((await readCanonical(facade, artifactId(preview))).success, true); + const verified = await facade.execute("of_work_verify", { stepId: "fixture-check" }); bounded(verified); + assert.equal((verified.data as { outcome: string }).outcome, "passed"); + assert.equal((verified.data as { before: { inputCount: number } }).before.inputCount, 452); + const fullEvidence = (await readCanonical(facade, artifactId(verified))).data as { before: { inputManifest: string[] } }; + assert.equal(fullEvidence.before.inputManifest.length, 452); + const status = await facade.execute("of_work_status", {}); bounded(status); + const again = await facade.execute("of_work_status", {}); assert.equal(artifactId(again), artifactId(status)); + const storeRoot = join(config.artifactRoot, (status.data as { attemptId: string }).attemptId); + const count = readdirSync(storeRoot).filter(name => name.endsWith(".artifact")).length; + await facade.execute("of_work_status", {}); + assert.equal(readdirSync(storeRoot).filter(name => name.endsWith(".artifact")).length, count); + const reconciled = await facade.execute("of_work_reconcile", {}); bounded(reconciled); + const finished = await facade.execute("of_work_finish", {}); bounded(finished); + assert.equal((finished.data as { state: string }).state, "completed"); + await controller.shutdown(); + const recovered = new WorkAttemptController(config); t.after(() => recovered.shutdown()); + const recoveredFacade = new ToolRegistry(); recoveredFacade.registerAll(createWorkTools(recovered)); + const terminal = await recoveredFacade.execute("of_work_status", {}); bounded(terminal); + assert.equal(artifactId(await recoveredFacade.execute("of_work_status", {})), artifactId(terminal)); + assert.deepEqual((await readCanonical(recoveredFacade, artifactId(status))).data, (await readCanonical(facade, artifactId(status))).data); +}); + +test("MCP wire evidence/status text and artifact chunks respect the output budget", async t => { + const { controller, facade } = await setup(t, { large: true }); await controller.start("job"); await correct(controller); + const server = createMcpServer(facade, { name: "output-tests" }); + const client = new Client({ name: "output-client", version: "1.0" }); + const [a, b] = InMemoryTransport.createLinkedPair(); await server.connect(a); await client.connect(b); + t.after(async () => { await client.close(); await server.close(); }); + for (const [name, args] of [["of_work_verify", { stepId: "fixture-check" }], ["of_work_status", {}]] as const) { + const wire = await client.callTool({ name, arguments: args }); + assert.ok(Array.isArray(wire.content)); + const text = (wire.content as { type: string; text?: string }[]).filter(part => part.type === "text").map(part => part.text ?? "").join(""); + assert.ok(Buffer.byteLength(text) <= 32768); + const id = JSON.parse(text).artifactProjection.artifact.id; + const chunk = await client.callTool({ name: "of_work_artifact_read", arguments: { artifactId: id } }); + assert.ok(Buffer.byteLength((chunk.content as { text: string }[])[0]!.text) <= 32768); + } +}); + +test("quota exhaustion reports unavailable output without changing completed work", async t => { + const { controller, facade, backend } = await setup(t, { large: true, quota: 4096 }); + const preview = await facade.execute("of_work_prepare", { workItemId: "job" }); bounded(preview); + assert.equal((preview.data as { artifactProjection: { available: boolean } }).artifactProjection.available, false); + await controller.start("job"); await correct(controller); await controller.verify("fixture-check"); await controller.finish(); + const status = await facade.execute("of_work_status", {}); bounded(status); + assert.equal(status.success, true); + assert.equal((status.data as { state: string }).state, "completed"); + assert.match((status.data as { artifactProjection: { reason: string } }).artifactProjection.reason, /quota exhausted/); + assert.equal((await backend.inspect("job")).state, "completed"); + assert.equal((await controller.status()).state, "completed"); +}); + +test("all facade operations bound stub outputs and preserve failed/unknown certainty", async () => { + for (const outcome of ["failed", "unknown"] as const) { + const oversized = { receipt: { executionId: "known-effect", executionOutcome: outcome }, outcome, text: "é".repeat(40000) }; + const stub = Object.fromEntries(["prepare", "start", "step", "verify", "status", "reconcile", "finish"].map(name => [name, async () => oversized])) as unknown as Parameters[0]; + for (const tool of createWorkTools(stub)) { + const result = await tool.handler({}); bounded(result); assert.equal(result.success, false); assert.equal(result.executionOutcome, outcome); + assert.equal((result.data as { outcome: string }).outcome, outcome); + } + } +}); + +test("artifact reader rejects paths and oversized direct reads", async t => { + const { controller } = await setup(t, { large: true }); + const output = controller.projectOutput({ success: true, data: await controller.prepare("job") }); + assert.throws(() => controller.readOutputArtifact("../../fixture.json"), /opaque artifact id/); + assert.throws(() => controller.readOutputArtifact(artifactId(output), { length: 16385 }), /bounds/); +}); + +test("thrown facade failures are bounded without losing unknown certainty or retrying", async () => { + for (const message of ["small failure", "é".repeat(40000)]) { + let calls = 0; + const stub = Object.fromEntries(["prepare", "start", "step", "verify", "status", "reconcile", "finish"].map(name => [name, async () => { calls++; throw new Error(message); }])) as unknown as Parameters[0]; + const registry = new ToolRegistry(); registry.registerAll(createWorkTools(stub)); + for (const tool of registry.getAll()) { + const params = tool.name === "of_work_prepare" || tool.name === "of_work_start" ? { workItemId: "job" } : tool.name === "of_work_verify" ? { stepId: "fixture-check" } : tool.name === "of_work_step" ? { stepKey: "correct", params: {} } : {}; + const reply = await registry.executeDetailed(tool.name, params); bounded(reply.result); + assert.equal(reply.outcome, "unknown"); assert.equal(reply.result.success, false); + assert.equal(reply.result.executionOutcome, "unknown"); + if (message === "small failure") assert.equal(reply.result.error, "Tool execution outcome is unknown: small failure"); + } + assert.equal(calls, 7); + } +}); + +test("a failing projection hook preserves known work and cannot cause redispatch or large output", async () => { + let calls = 0; + const canonical = { state: "completed", text: "x".repeat(40000) }; + const stub = { ...Object.fromEntries(["prepare", "start", "step", "verify", "status", "reconcile", "finish"].map(name => [name, async () => { calls++; return canonical; }])), projectOutput: () => { throw new Error("projection failed"); } } as unknown as Parameters[0]; + for (const tool of createWorkTools(stub)) { + const reply = await tool.handler({}); bounded(reply); + assert.equal(reply.success, true); assert.equal((reply.data as { state: string }).state, "completed"); + assert.equal((reply.data as { artifactProjection: { available: boolean } }).artifactProjection.available, false); + } + assert.equal(calls, 7); +}); diff --git a/test-client/run-work-tests.ts b/test-client/run-work-tests.ts index 162f858..afbeb06 100644 --- a/test-client/run-work-tests.ts +++ b/test-client/run-work-tests.ts @@ -295,6 +295,53 @@ test("queued writes refuse a lease replaced by a successor",async t=>{ assert.equal(result.receipt.phase,"not_started");assert.equal(effects,0);assert.equal((await local.inspect("job")).lease!.owner,"successor"); }); +test("queued verification refuses successor lease ownership before its command runs",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release());let verifications=0; + let requested!:()=>void;const request=new Promise(resolve=>requested=resolve); + const {controller,config,bindings,local}=await setup(t,{journal:{record:event=>{if(event.type === "requested" && event.data.toolName === "_of_verify_fixture_check")requested();}}}); + const binding=bindings.verificationBindings[0]!;const run=binding.run; + binding.run=async context=>{verifications++;return run(context);}; + await controller.start("job");await correct(controller); + config.registry.register({name:"hold_verification_custody",description:"Hold the shared queue before verification",inputSchema:{type:"object",properties:{}},handler:async()=>{barrier.entered();await barrier.waiting;return {success:true};}}); + const hold=config.registry.executeDetailed("hold_verification_custody",{});await barrier.entry; + const pending=controller.verify("fixture-check");const rejection=assert.rejects(pending,/authority|grant|lease/i); + await request;await new Promise(resolve=>setImmediate(resolve)); + await local.release((await local.inspect("job")).lease!,"verification-release"); + const successor=await local.open({workItemId:"job",claimOwner:"successor",leaseSeconds:120,idempotencyKey:"verification-successor"}); + await local.transition(successor,"start",{},"verification-successor-start"); + barrier.release();await hold;await rejection; + assert.equal(verifications,0);assert.equal((await local.inspect("job")).lease!.owner,"successor"); +}); + +test("completion refuses a subject changed during its awaited readback",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release()); + const {controller,fixture,bindings,local}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check"); + const binding=bindings.verificationBindings[0]!;const original=binding.readback;let calls=0; + binding.readback=async root=>{if(++calls === 1){barrier.entered();await barrier.waiting;}return original(root);}; + const pending=controller.finish();await barrier.entry; + writeFileSync(fixture,JSON.stringify({...JSON.parse(readFileSync(fixture,"utf8")),unverified:true}));barrier.release(); + assert.equal((await pending).state,"needs_review");assert.equal((await local.inspect("job")).state,"needs_review"); +}); + +test("completion rechecks its subject after the final awaited grant lookup",async t=>{ + const barrier=reviewBarrier();t.after(()=>barrier.release()); + const {controller,fixture,grants,local}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check"); + const original=grants.resolve.bind(grants);let completionLookups=0; + grants.resolve=async (binding,purpose)=>{if(purpose === "completion" && ++completionLookups === 2){barrier.entered();await barrier.waiting;}return original(binding,purpose);}; + const pending=controller.finish();const rejection=assert.rejects(pending,/subject changed/i);await barrier.entry; + writeFileSync(fixture,JSON.stringify({...JSON.parse(readFileSync(fixture,"utf8")),unverified:true}));barrier.release();await rejection; + assert.equal((await local.inspect("job")).state,"running");assert.equal((await controller.status()).state,"reconciliation_required"); +}); + +test("verification includes awaited readback changes in its subject fingerprint",async t=>{ + const {controller,fixture,bindings,local}=await setup(t);await controller.start("job");await correct(controller); + const binding=bindings.verificationBindings[0]!;const original=binding.readback;let calls=0; + binding.readback=async root=>{if(++calls === 1)writeFileSync(fixture,JSON.stringify({...JSON.parse(readFileSync(fixture,"utf8")),unverified:true}));return original(root);}; + const evidence=await controller.verify("fixture-check"); + assert.equal(evidence.outcome,"inconclusive");assert.notEqual(evidence.before.digest,evidence.after.digest); + assert.equal((await controller.finish()).state,"needs_review");assert.equal((await local.inspect("job")).state,"needs_review"); +}); + for(const change of ["completion","execution","expiry"] as const)test(`terminal dispatch refuses ${change} authority loss during proof readback`,async t=>{ const barrier=reviewBarrier();t.after(()=>barrier.release()); const {controller,bindings,grants,local,advance}=await setup(t);await controller.start("job");await correct(controller);await controller.verify("fixture-check");