From 1b37f4dfa5aea397103d28c3fc4088625802b3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:55:31 +0800 Subject: [PATCH 1/4] feat(mcp): materialize safe OpenCode HTTP configuration (#488) --- README.md | 25 +- deno.json | 8 +- deno.lock | 18 +- package.json | 4 +- src/base/agent-plugin.ts | 17 +- src/main.ts | 15 + src/mcp/README.md | 19 + src/mcp/definition.ts | 21 + src/mcp/errors.ts | 23 + src/mcp/filesystem.ts | 141 +++++ src/mcp/fingerprint.ts | 16 + src/mcp/git.ts | 152 +++++ src/mcp/ledger.ts | 119 ++++ src/mcp/materializer.ts | 397 ++++++++++++ src/mcp/native-key.ts | 53 ++ .../mcp-configuration/receipts/valid.json | 13 + tests/host-simulator.ts | 22 +- tests/mcp-materializer.test.ts | 589 ++++++++++++++++++ tests/mcp-registration.test.ts | 94 +++ 19 files changed, 1733 insertions(+), 13 deletions(-) create mode 100644 src/mcp/README.md create mode 100644 src/mcp/definition.ts create mode 100644 src/mcp/errors.ts create mode 100644 src/mcp/filesystem.ts create mode 100644 src/mcp/fingerprint.ts create mode 100644 src/mcp/git.ts create mode 100644 src/mcp/ledger.ts create mode 100644 src/mcp/materializer.ts create mode 100644 src/mcp/native-key.ts create mode 100644 tests/fixtures/mcp-configuration/receipts/valid.json create mode 100644 tests/mcp-materializer.test.ts create mode 100644 tests/mcp-registration.test.ts diff --git a/README.md b/README.md index 51cfc6b..a6b63ad 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ conversation runs against the OpenCode CLI through its native to run by hand. - Streams OpenCode's models, sessions, and responses straight through to Ora's UI via ACP, including the in-session model picker. +- Materializes Ora's complete HTTP MCP snapshot into the Workspace-local, + exclusively managed `.opencode/opencode.json` document. OpenCode 0.3.0 does + not advertise stdio MCP support, so the Host skips those MCPs without blocking + supported HTTP servers. - Ships with the OpenCode CLI bundled inside the package, so there is nothing else to install. @@ -46,7 +50,7 @@ cmd /c mklink /J "\plugins" "%USERPROFILE%\.ora\plugins\installed" ``` deno task build -deno task package --tag v0.2.4 --repo ora-space/opencode-agent +deno task package --tag v0.3.0 --repo ora-space/opencode-agent ``` This produces one `.orax` package per target platform, with the matching @@ -71,6 +75,25 @@ the binary under `assets/bin/` — never one on your `PATH`. That directory is git-ignored and is only needed for this. `deno task check` type checks and `deno task lint` lints the sources. +`deno task test` exercises MCP materialization in temporary Git and non-Git +Workspaces. + +## Managed MCP configuration + +Ora-generated MCP configuration is intentionally separate from user-owned +OpenCode configuration. The plugin never modifies project-root `opencode.json` +or `opencode.jsonc`. It writes only `.opencode/opencode.json`, and only when its +private applied/prepared fingerprint ledger proves ownership; a pre-existing or +externally changed file is preserved as a blocking conflict. + +In a Git Workspace, only `/.opencode/opencode.json` is added to the repository's +local exclude file. The plugin does not touch `.gitignore` or ignore the +`.opencode` directory, so the neighboring Skill surface remains visible. The +managed document is atomically replaced from a same-directory staging file and +restricted to the current OS account because protocol v1 may contain resolved +plaintext headers. Root configuration collisions, tracked managed paths, Git +failures, and permission failures leave the previous committed document +unchanged. ## Known limits diff --git a/deno.json b/deno.json index b8fe91e..b8395c0 100644 --- a/deno.json +++ b/deno.json @@ -1,18 +1,20 @@ { "name": "@ora-space/opencode-agent", - "version": "0.1.0", + "version": "0.3.0", "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { - "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.5.0", + "@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.6.0", "@std/cli": "jsr:@std/cli@^1.0.32", + "@std/jsonc": "jsr:@std/jsonc@^1.0.2", "@std/path": "jsr:@std/path@^1.1.6", "@std/tar": "jsr:@std/tar@^0.1.10", "@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61" }, "tasks": { - "check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts", + "check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts tests/mcp-materializer.test.ts", "lint": "deno lint src scripts tests bundle.config.ts", + "test": "deno test --allow-read --allow-write --allow-run tests/mcp-materializer.test.ts tests/mcp-registration.test.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md", "simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts", "dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts", diff --git a/deno.lock b/deno.lock index 9ba08bc..282ab36 100644 --- a/deno.lock +++ b/deno.lock @@ -1,19 +1,17 @@ { "version": "5", "specifiers": { - "jsr:@ora-space/plugin-sdk@0.5.0": "0.5.0", "jsr:@std/cli@^1.0.32": "1.0.32", "jsr:@std/fmt@^1.0.10": "1.0.10", "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/json@^1.0.2": "1.1.0", + "jsr:@std/jsonc@^1.0.2": "1.0.2", "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.0.17": "1.1.2", "jsr:@std/tar@~0.1.10": "0.1.10", "jsr:@zip-js/zip-js@^2.8.61": "2.8.61" }, "jsr": { - "@ora-space/plugin-sdk@0.5.0": { - "integrity": "421c79eaefef92b087bd93ff9a12e48222a599fecd8c49cfcf5d74df4c48eef3" - }, "@std/cli@1.0.32": { "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", "dependencies": [ @@ -27,6 +25,15 @@ "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, + "@std/json@1.1.0": { + "integrity": "93330d3ed4054d6b1ffe54b075432c80a5f671be77277b978931e018014cb1cc" + }, + "@std/jsonc@1.0.2": { + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", + "dependencies": [ + "jsr:@std/json" + ] + }, "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ @@ -48,8 +55,9 @@ }, "workspace": { "dependencies": [ - "jsr:@ora-space/plugin-sdk@0.5.0", + "jsr:@ora-space/plugin-sdk@0.6.0", "jsr:@std/cli@^1.0.32", + "jsr:@std/jsonc@^1.0.2", "jsr:@std/path@^1.1.6", "jsr:@std/tar@~0.1.10", "jsr:@zip-js/zip-js@^2.8.61" diff --git a/package.json b/package.json index b4cb27c..ebf0177 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ora-space/opencode-agent", - "version": "0.1.0", - "description": "Ora agent plugin that runs OpenCode as an ACP agent.", + "version": "0.3.0", + "description": "Ora ACP agent plugin with safe HTTP MCP materialization for OpenCode.", "type": "module", "license": "Apache-2.0", "ora": { diff --git a/src/base/agent-plugin.ts b/src/base/agent-plugin.ts index 7b77379..ac7e5fa 100644 --- a/src/base/agent-plugin.ts +++ b/src/base/agent-plugin.ts @@ -1,12 +1,15 @@ import { type AcpSender, type AgentEffectDefinition, + type AgentMcpConfigurationDefinition, type AgentModel, type AgentStartContext, createHostProcesses, + createStorage, defineAgent, type HostProcesses, type JsonValue, + type PluginStorage, } from "@ora-space/plugin-sdk"; /** @@ -20,6 +23,7 @@ import { export interface PluginContext { readonly pluginId: string; readonly processes: HostProcesses; + readonly storage: PluginStorage; } /** What the caller of {@link runAgentPlugin} supplies; `processes` is assembled internally. */ @@ -98,6 +102,15 @@ export abstract class AgentPlugin { * way `onStart` and friends are mounted above. */ effects: AgentEffectDefinition | undefined = undefined; + + /** + * Declares MCP capability and handler as one SDK definition. + * + * Keeping this property high-level is intentional: the SDK is the only layer allowed to pair + * the registration capability with `agent/configureWorkspace`, so subclasses cannot publish a + * one-sided contract. + */ + mcpConfiguration: AgentMcpConfigurationDefinition | undefined = undefined; } /** One entry of the flattened dispatch table, already bound to its plugin instance. */ @@ -136,9 +149,11 @@ export async function runAgentPlugin( | void | Promise, effects: plugin.effects, + mcpConfiguration: plugin.mcpConfiguration, }); const processes = createHostProcesses(definition); - await plugin.onActivate({ pluginId: options.pluginId, processes }); + const storage = createStorage(definition); + await plugin.onActivate({ pluginId: options.pluginId, processes, storage }); try { await definition.run(); diff --git a/src/main.ts b/src/main.ts index c9bf8ca..f0cee67 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,12 @@ import { SkillEffectCoordinator } from "./handlers/effects.ts"; import { startOpenCode, stopOpenCode } from "./handlers/lifecycle.ts"; import { listOpenCodeModels } from "./handlers/models.ts"; import { OpenCodeClient } from "./services/opencode-client.ts"; +import { defineOpenCodeMcpConfiguration } from "./mcp/definition.ts"; +import { PluginStorageManagedStateStore } from "./mcp/ledger.ts"; +import { + createOpenCodeMcpMaterializer, + type OpenCodeMcpMaterializer, +} from "./mcp/materializer.ts"; /** Must match `ora.id` in package.json, which is also this agent's identity inside Ora. */ const PLUGIN_ID = "ora-space.opencode"; @@ -33,6 +39,8 @@ class OpenCodeAgentPlugin extends AgentPlugin { #cwd: string | undefined; /** Set by `onActivate`, which the base class runs before the host can call anything. */ #processes: HostProcesses | undefined; + /** Installed during activation, before the SDK starts accepting configuration calls. */ + #mcpMaterializer: OpenCodeMcpMaterializer | undefined; readonly #client = new OpenCodeClient({ onAcpFrame: (frame) => { @@ -54,10 +62,17 @@ class OpenCodeAgentPlugin extends AgentPlugin { override readonly effects = this.#effects.definition; + override readonly mcpConfiguration = defineOpenCodeMcpConfiguration( + () => this.#mcpMaterializer, + ); + override onActivate(context: PluginContext): void { console.info(`${context.pluginId} activated`); this.#processes = context.processes; this.#client.attachProcesses(context.processes); + this.#mcpMaterializer = createOpenCodeMcpMaterializer( + new PluginStorageManagedStateStore(context.storage), + ); } override onStart = async ( diff --git a/src/mcp/README.md b/src/mcp/README.md new file mode 100644 index 0000000..6d142fc --- /dev/null +++ b/src/mcp/README.md @@ -0,0 +1,19 @@ +# OpenCode MCP materialization + +This module owns the OpenCode-specific half of Ora's MCP Configuration +Capability. It converts each complete protocol-v1 HTTP snapshot into the single +Workspace document `.opencode/opencode.json`; it does not merge with or modify +the project-root OpenCode configuration. + +The materializer treats the generated document as an all-or-nothing managed +resource. A private plugin-storage ledger records both the last applied +fingerprint and a prepared operation before filesystem mutation. That evidence +allows an interrupted operation to be replayed without adopting a file merely +because it occupies the managed path. Root configuration collisions, tracked +files, Git exclude failures, permission failures, and fingerprint drift are +blocking preserved-state failures. + +Filesystem staging, atomic replacement, permission restriction, Git inspection, +and ledger persistence are separate injectable ports. Production uses Deno and +Git implementations; tests substitute narrow failure implementations so cleanup +and previous-document preservation remain observable. diff --git a/src/mcp/definition.ts b/src/mcp/definition.ts new file mode 100644 index 0000000..caaece0 --- /dev/null +++ b/src/mcp/definition.ts @@ -0,0 +1,21 @@ +import type { AgentMcpConfigurationDefinition } from "@ora-space/plugin-sdk"; +import { McpMaterializationError } from "./errors.ts"; +import type { OpenCodeMcpMaterializer } from "./materializer.ts"; + +/** Builds the one high-level SDK definition that pairs OpenCode's HTTP capability and handler. */ +export function defineOpenCodeMcpConfiguration( + materializer: () => OpenCodeMcpMaterializer | undefined, +): AgentMcpConfigurationDefinition { + return { + protocolVersion: 1, + transports: ["http"], + coordination: "wait_for_idle_and_restart", + configureWorkspace: (request) => { + const active = materializer(); + if (active === undefined) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + return active.configureWorkspace(request); + }, + }; +} diff --git a/src/mcp/errors.ts b/src/mcp/errors.ts new file mode 100644 index 0000000..1de04a0 --- /dev/null +++ b/src/mcp/errors.ts @@ -0,0 +1,23 @@ +/** Stable public failure codes emitted by OpenCode MCP materialization. */ +export type McpMaterializationErrorCode = + | "mcp_materialization_conflict" + | "mcp_native_key_collision" + | "mcp_config_file_tracked" + | "mcp_config_git_exclude_failed" + | "mcp_config_permissions_failed"; + +/** + * Carries only a stable code so a thrown error cannot accidentally echo snapshot secrets. + * + * The SDK converts the message to JSON-RPC; keeping it equal to the code makes both the wire + * failure and plugin stderr safe even when an upstream error contains a URL or header value. + */ +export class McpMaterializationError extends Error { + readonly code: McpMaterializationErrorCode; + + constructor(code: McpMaterializationErrorCode) { + super(code); + this.name = "McpMaterializationError"; + this.code = code; + } +} diff --git a/src/mcp/filesystem.ts b/src/mcp/filesystem.ts new file mode 100644 index 0000000..9093223 --- /dev/null +++ b/src/mcp/filesystem.ts @@ -0,0 +1,141 @@ +import { dirname, join } from "@std/path"; +import { McpMaterializationError } from "./errors.ts"; + +/** Filesystem operations kept separate from permission and replacement policy for fault tests. */ +export interface MaterializationFileSystem { + assertSafeManagedPaths( + directoryPath: string, + targetPath: string, + ): Promise; + read(path: string): Promise; + ensureDirectory(path: string): Promise; + createStagingFile(targetPath: string, bytes: Uint8Array): Promise; + removeFile(path: string): Promise; + cleanup(path: string): Promise; +} + +/** Applies the current-user-only policy before plaintext can reach the managed pathname. */ +export interface PermissionRestrictor { + restrict(path: string): Promise; +} + +/** Commits one already-restricted same-directory staging file as the managed document. */ +export interface AtomicReplacer { + replace(stagingPath: string, targetPath: string): Promise; +} + +/** Production Deno filesystem implementation with exclusive same-directory staging files. */ +export class DenoMaterializationFileSystem + implements MaterializationFileSystem { + async assertSafeManagedPaths( + directoryPath: string, + targetPath: string, + ): Promise { + // A linked `.opencode` directory would move plaintext outside the Workspace while every + // lexical containment check still passed. Existing target links are rejected for the same + // reason even when their bytes happen to match an applied fingerprint. + for (const path of [directoryPath, targetPath]) { + try { + if ((await Deno.lstat(path)).isSymlink) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + throw error; + } + } + } + + async read(path: string): Promise { + try { + return await Deno.readFile(path); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return undefined; + } + throw error; + } + } + + async ensureDirectory(path: string): Promise { + await Deno.mkdir(path, { recursive: true, mode: 0o700 }); + } + + async createStagingFile( + targetPath: string, + bytes: Uint8Array, + ): Promise { + const stagingPath = join( + dirname(targetPath), + `.opencode.json.ora-${crypto.randomUUID()}.tmp`, + ); + const file = await Deno.open(stagingPath, { + createNew: true, + write: true, + mode: 0o600, + }); + try { + await file.write(bytes); + await file.sync(); + } catch (error) { + file.close(); + await this.cleanup(stagingPath); + throw error; + } + file.close(); + return stagingPath; + } + + async removeFile(path: string): Promise { + await Deno.remove(path); + } + + async cleanup(path: string): Promise { + await Deno.remove(path).catch(() => undefined); + } +} + +/** Restricts staging bytes to the current account on Unix and Windows before publication. */ +export class CurrentUserPermissionRestrictor implements PermissionRestrictor { + async restrict(path: string): Promise { + try { + if (Deno.build.os !== "windows") { + await Deno.chmod(path, 0o600); + return; + } + const user = new TextDecoder().decode( + (await new Deno.Command("whoami", { stdout: "piped" }).output()).stdout, + ).trim(); + if (user.length === 0) { + throw new Error("current account is unavailable"); + } + const result = await new Deno.Command("icacls", { + args: [path, "/inheritance:r", "/grant:r", `${user}:(F)`], + stdout: "null", + stderr: "null", + }).output(); + if (!result.success) { + throw new Error("permission restriction failed"); + } + } catch { + throw new McpMaterializationError("mcp_config_permissions_failed"); + } + } +} + +/** Uses the platform rename primitive, which atomically replaces a file on the same volume. */ +export class DenoAtomicReplacer implements AtomicReplacer { + async replace(stagingPath: string, targetPath: string): Promise { + await Deno.rename(stagingPath, targetPath); + // Syncing the committed file keeps the success boundary after the bytes are durable. Directory + // sync is unavailable on Windows and the rename primitive already provides its only boundary. + const committed = await Deno.open(targetPath, { read: true, write: true }); + try { + await committed.sync(); + } finally { + committed.close(); + } + } +} diff --git a/src/mcp/fingerprint.ts b/src/mcp/fingerprint.ts new file mode 100644 index 0000000..78e448c --- /dev/null +++ b/src/mcp/fingerprint.ts @@ -0,0 +1,16 @@ +/** Returns the Host-canonical SHA-256 fingerprint of exactly the supplied bytes. */ +export async function fingerprintBytes(bytes: Uint8Array): Promise { + // Copying narrows a possibly shared backing buffer to the ArrayBuffer WebCrypto accepts. + const input = new Uint8Array(bytes.byteLength); + input.set(bytes); + const digest = await crypto.subtle.digest("SHA-256", input); + const hex = Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return `sha256:${hex}`; +} + +/** Returns the lowercase SHA-256 hex of one UTF-8 string for native-key derivation. */ +export async function sha256Hex(value: string): Promise { + return (await fingerprintBytes(new TextEncoder().encode(value))).slice(7); +} diff --git a/src/mcp/git.ts b/src/mcp/git.ts new file mode 100644 index 0000000..4432d8c --- /dev/null +++ b/src/mcp/git.ts @@ -0,0 +1,152 @@ +import { dirname, isAbsolute, join, normalize, relative } from "@std/path"; +import type { + AtomicReplacer, + MaterializationFileSystem, +} from "./filesystem.ts"; +import { McpMaterializationError } from "./errors.ts"; + +const MANAGED_EXCLUDE = "/.opencode/opencode.json"; + +/** Verifies repository safety and prepares only the repository-local exclude. */ +export interface GitWorkspaceGuard { + prepare(workspaceRoot: string, managedPath: string): Promise; +} + +interface CommandResult { + success: boolean; + code: number; + stdout: string; +} + +export type GitCommand = ( + workspaceRoot: string, + args: readonly string[], +) => Promise; + +/** + * Performs Git inspection before plaintext staging and atomically updates the local exclude. + * + * Exit details are deliberately discarded from public failures because Git can include absolute + * paths. Exit 128 from the initial probe is the only non-error outcome: it identifies a non-Git + * Workspace, where the rest of the safety policy still runs. + */ +export class RepositoryLocalGitGuard implements GitWorkspaceGuard { + readonly #run: GitCommand; + readonly #fs: MaterializationFileSystem; + readonly #replacer: AtomicReplacer; + + constructor( + fs: MaterializationFileSystem, + replacer: AtomicReplacer, + run: GitCommand = runGit, + ) { + this.#fs = fs; + this.#replacer = replacer; + this.#run = run; + } + + async prepare(workspaceRoot: string, managedPath: string): Promise { + const topLevel = await this.#run(workspaceRoot, [ + "rev-parse", + "--show-toplevel", + ]); + if (!topLevel.success) { + if (topLevel.code === 128) { + return; + } + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } + const repositoryRoot = normalize(topLevel.stdout.trim()); + let normalizedWorkspace: string; + try { + // Windows temporary/worktree paths may arrive in 8.3 form while Git returns the long form. + normalizedWorkspace = normalize(await Deno.realPath(workspaceRoot)); + } catch { + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } + if ( + repositoryRoot.length === 0 || + repositoryRoot.toLowerCase() !== normalizedWorkspace.toLowerCase() || + relative(normalize(workspaceRoot), normalize(managedPath)).replaceAll( + "\\", + "/", + ) !== + ".opencode/opencode.json" + ) { + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } + + const tracked = await this.#run(workspaceRoot, [ + "ls-files", + "--error-unmatch", + "--", + ".opencode/opencode.json", + ]); + if (tracked.success) { + throw new McpMaterializationError("mcp_config_file_tracked"); + } + if (tracked.code !== 1) { + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } + + const excludeResult = await this.#run(workspaceRoot, [ + "rev-parse", + "--git-path", + "info/exclude", + ]); + if (!excludeResult.success || excludeResult.stdout.trim().length === 0) { + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } + const rawExclude = excludeResult.stdout.trim(); + const excludePath = normalize( + isAbsolute(rawExclude) ? rawExclude : join(workspaceRoot, rawExclude), + ); + await this.#ensureExactExclude(excludePath); + } + + async #ensureExactExclude(excludePath: string): Promise { + try { + const existing = await this.#fs.read(excludePath) ?? new Uint8Array(); + const text = new TextDecoder("utf-8", { fatal: true }).decode(existing); + if (text.split(/\r?\n/).includes(MANAGED_EXCLUDE)) { + return; + } + const separator = text.length === 0 || text.endsWith("\n") ? "" : "\n"; + const desired = new TextEncoder().encode( + `${text}${separator}${MANAGED_EXCLUDE}\n`, + ); + await this.#fs.ensureDirectory(dirname(excludePath)); + const staging = await this.#fs.createStagingFile(excludePath, desired); + try { + await this.#replacer.replace(staging, excludePath); + } finally { + await this.#fs.cleanup(staging); + } + } catch (error) { + if (error instanceof McpMaterializationError) { + throw error; + } + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } + } +} + +async function runGit( + workspaceRoot: string, + args: readonly string[], +): Promise { + try { + const result = await new Deno.Command("git", { + args: ["-C", workspaceRoot, ...args], + stdout: "piped", + stderr: "null", + }).output(); + return { + success: result.success, + code: result.code, + stdout: new TextDecoder().decode(result.stdout), + }; + } catch { + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } +} diff --git a/src/mcp/ledger.ts b/src/mcp/ledger.ts new file mode 100644 index 0000000..b4dd0d9 --- /dev/null +++ b/src/mcp/ledger.ts @@ -0,0 +1,119 @@ +import type { PluginStorage } from "@ora-space/plugin-sdk"; +import { fingerprintBytes } from "./fingerprint.ts"; + +export interface AppliedManagedDocument { + fingerprint: string; +} + +export interface PreparedManagedDocumentOperation { + operationId: string; + desiredFingerprint: string; + previousFingerprint: string | undefined; + deleting: boolean; +} + +export interface ManagedDocumentState { + applied: AppliedManagedDocument | undefined; + prepared: PreparedManagedDocumentOperation | undefined; +} + +/** Persists ownership proof independently from the managed document's target-native bytes. */ +export interface ManagedStateStore { + read(agentTargetId: string): Promise; + write( + agentTargetId: string, + state: ManagedDocumentState, + ): Promise; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); + +/** Stores non-secret fingerprints in the plugin's Host-protected private storage namespace. */ +export class PluginStorageManagedStateStore implements ManagedStateStore { + readonly #storage: PluginStorage; + + constructor(storage: PluginStorage) { + this.#storage = storage; + } + + async read( + agentTargetId: string, + ): Promise { + try { + const parsed = JSON.parse( + decoder.decode( + await this.#storage.read(await statePath(agentTargetId)), + ), + ); + return parseState(parsed); + } catch (error) { + if (isNotFound(error)) { + return undefined; + } + throw error; + } + } + + async write( + agentTargetId: string, + state: ManagedDocumentState, + ): Promise { + const bytes = encoder.encode( + JSON.stringify({ schemaVersion: 1, ...state }), + ); + await this.#storage.write(await statePath(agentTargetId), bytes); + } +} + +/** Hashing the target id keeps private storage paths bounded and slash-safe. */ +async function statePath(agentTargetId: string): Promise { + const fingerprint = await fingerprintBytes(encoder.encode(agentTargetId)); + return `mcp-managed-state/${fingerprint.slice(7)}.json`; +} + +function parseState(value: unknown): ManagedDocumentState { + if (!isRecord(value) || value.schemaVersion !== 1) { + throw new Error("invalid MCP managed-state ledger"); + } + const applied = value.applied; + const prepared = value.prepared; + if ( + applied !== undefined && + (!isRecord(applied) || !isFingerprint(applied.fingerprint)) + ) { + throw new Error("invalid MCP managed-state ledger"); + } + if ( + prepared !== undefined && + ( + !isRecord(prepared) || + typeof prepared.operationId !== "string" || + !isFingerprint(prepared.desiredFingerprint) || + ( + prepared.previousFingerprint !== undefined && + !isFingerprint(prepared.previousFingerprint) + ) || + typeof prepared.deleting !== "boolean" + ) + ) { + throw new Error("invalid MCP managed-state ledger"); + } + return { + applied: applied as AppliedManagedDocument | undefined, + prepared: prepared as PreparedManagedDocumentOperation | undefined, + }; +} + +function isFingerprint(value: unknown): value is string { + return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value); +} + +function isNotFound(error: unknown): boolean { + return typeof error === "object" && error !== null && + "kind" in error && error.kind === "not_found"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/mcp/materializer.ts b/src/mcp/materializer.ts new file mode 100644 index 0000000..4be032a --- /dev/null +++ b/src/mcp/materializer.ts @@ -0,0 +1,397 @@ +import type { + McpConfigurationReceipt, + McpConfigurationSnapshotRequest, + McpEntryReceipt, + SnapshotResolvedMcp, +} from "@ora-space/plugin-sdk"; +import { parse as parseJsonc } from "@std/jsonc"; +import { join } from "@std/path"; +import { McpMaterializationError } from "./errors.ts"; +import { + type AtomicReplacer, + CurrentUserPermissionRestrictor, + DenoAtomicReplacer, + DenoMaterializationFileSystem, + type MaterializationFileSystem, + type PermissionRestrictor, +} from "./filesystem.ts"; +import { fingerprintBytes } from "./fingerprint.ts"; +import { type GitWorkspaceGuard, RepositoryLocalGitGuard } from "./git.ts"; +import type { ManagedDocumentState, ManagedStateStore } from "./ledger.ts"; +import { + assertNoNativeKeyCollisions, + type IdentityDigest, + nativeMcpKey, +} from "./native-key.ts"; + +export const MANAGED_DOCUMENT_LOCATOR = ".opencode/opencode.json"; +const OPEN_CODE_SCHEMA = "https://opencode.ai/config.json"; +const encoder = new TextEncoder(); + +interface OpenCodeRemoteEntry { + type: "remote"; + url: string; + enabled: true; + oauth: false; + headers: Record; +} + +interface PlannedEntry { + canonicalIdentity: string; + managedIdentity: string; + nativeKey: string; + sourceRevisionId: string; + value: OpenCodeRemoteEntry; + receipt: McpEntryReceipt; +} + +/** Dependencies whose failure behavior is significant to the all-or-nothing write contract. */ +export interface McpMaterializerDependencies { + fileSystem: MaterializationFileSystem; + permissions: PermissionRestrictor; + atomicReplacer: AtomicReplacer; + git: GitWorkspaceGuard; + state: ManagedStateStore; + identityDigest?: IdentityDigest; +} + +/** + * Reconciles a complete supported snapshot into OpenCode's exclusively Ora-managed document. + * + * Every public failure is reduced to a stable code. In particular, filesystem and parser errors + * are never interpolated because their original messages may contain an absolute path or content. + */ +export class OpenCodeMcpMaterializer { + readonly #dependencies: McpMaterializerDependencies; + + constructor(dependencies: McpMaterializerDependencies) { + this.#dependencies = dependencies; + } + + async configureWorkspace( + request: McpConfigurationSnapshotRequest, + ): Promise { + try { + return await this.#configureWorkspace(request); + } catch (error) { + if (error instanceof McpMaterializationError) { + throw error; + } + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + + async #configureWorkspace( + request: McpConfigurationSnapshotRequest, + ): Promise { + const plans = await this.#planEntries(request.resolvedMcps); + const documentBytes = renderDocument(plans); + const documentFingerprint = await fingerprintBytes(documentBytes); + const managedDirectory = join(request.workspaceRoot, ".opencode"); + const targetPath = join( + request.workspaceRoot, + ...MANAGED_DOCUMENT_LOCATOR.split("/"), + ); + await this.#dependencies.fileSystem.assertSafeManagedPaths( + managedDirectory, + targetPath, + ); + const observedBytes = await this.#dependencies.fileSystem.read(targetPath); + const observedFingerprint = observedBytes === undefined + ? undefined + : await fingerprintBytes(observedBytes); + const state = await this.#readState(request.agentTargetId); + + this.#assertOwnership( + request, + state, + observedFingerprint, + documentFingerprint, + plans.length === 0, + ); + + if (plans.length > 0) { + await this.#assertRootConfigurationPreserved( + request.workspaceRoot, + plans, + ); + } + if (observedBytes !== undefined || plans.length > 0) { + await this.#dependencies.git.prepare(request.workspaceRoot, targetPath); + } + + const receipt = receiptFor(request, documentFingerprint, plans); + if (plans.length === 0) { + return await this.#deleteManagedDocument( + request, + state, + observedBytes, + documentFingerprint, + receipt, + targetPath, + ); + } + + if (observedFingerprint === documentFingerprint) { + await this.#commitState(request.agentTargetId, documentFingerprint); + return receipt; + } + + await this.#prepareState( + request, + state, + documentFingerprint, + false, + ); + await this.#dependencies.fileSystem.ensureDirectory(managedDirectory); + await this.#dependencies.fileSystem.assertSafeManagedPaths( + managedDirectory, + targetPath, + ); + const stagingPath = await this.#dependencies.fileSystem.createStagingFile( + targetPath, + documentBytes, + ); + try { + await this.#dependencies.permissions.restrict(stagingPath); + await this.#dependencies.atomicReplacer.replace(stagingPath, targetPath); + } finally { + await this.#dependencies.fileSystem.cleanup(stagingPath); + } + await this.#commitState(request.agentTargetId, documentFingerprint); + return receipt; + } + + async #planEntries( + resolvedMcps: readonly SnapshotResolvedMcp[], + ): Promise { + const plans = await Promise.all(resolvedMcps.map(async (mcp) => { + if (mcp.transport.kind !== "http") { + // The Host must exclude stdio after HTTP-only negotiation; accepting it here would let a + // malformed request accidentally enter either the document or the success receipt. + throw new McpMaterializationError("mcp_materialization_conflict"); + } + let url: URL; + try { + url = new URL(mcp.transport.url); + } catch { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + if (url.protocol !== "https:" || url.hostname.length === 0) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + const nativeKey = await nativeMcpKey( + mcp.canonicalIdentity, + this.#dependencies.identityDigest, + ); + const value: OpenCodeRemoteEntry = { + type: "remote", + url: mcp.transport.url, + enabled: true, + oauth: false, + headers: Object.fromEntries( + Object.entries(mcp.transport.headers).sort(([left], [right]) => + left.localeCompare(right) + ), + ), + }; + return { + canonicalIdentity: mcp.canonicalIdentity, + managedIdentity: mcp.managedIdentity, + nativeKey, + sourceRevisionId: mcp.sourceRevisionId, + value, + receipt: { + managedIdentity: mcp.managedIdentity, + nativeKey, + entryFingerprint: await fingerprintBytes( + encoder.encode(JSON.stringify(value)), + ), + sourceRevisionId: mcp.sourceRevisionId, + }, + }; + })); + plans.sort((left, right) => left.nativeKey.localeCompare(right.nativeKey)); + assertNoNativeKeyCollisions(plans); + if ( + new Set(plans.map((entry) => entry.managedIdentity)).size !== + plans.length || + new Set(plans.map((entry) => entry.nativeKey)).size !== plans.length + ) { + throw new McpMaterializationError("mcp_native_key_collision"); + } + return plans; + } + + #assertOwnership( + request: McpConfigurationSnapshotRequest, + state: ManagedDocumentState | undefined, + observedFingerprint: string | undefined, + desiredFingerprint: string, + deleting: boolean, + ): void { + const appliedMatches = observedFingerprint !== undefined && + state?.applied?.fingerprint === observedFingerprint; + const preparedMatches = + state?.prepared?.operationId === request.operationId && + state.prepared.desiredFingerprint === desiredFingerprint && + state.prepared.deleting === deleting && + (deleting + ? observedFingerprint === undefined + : observedFingerprint === desiredFingerprint); + + if ( + observedFingerprint !== undefined && !appliedMatches && !preparedMatches + ) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + if ( + observedFingerprint === undefined && state?.applied !== undefined && + !preparedMatches + ) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + + async #assertRootConfigurationPreserved( + workspaceRoot: string, + plans: readonly PlannedEntry[], + ): Promise { + const nativeKeys = new Set(plans.map((plan) => plan.nativeKey)); + for (const fileName of ["opencode.json", "opencode.jsonc"]) { + const bytes = await this.#dependencies.fileSystem.read( + join(workspaceRoot, fileName), + ); + if (bytes === undefined) { + continue; + } + let parsed: unknown; + try { + parsed = parseJsonc( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + } catch { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + if (!isRecord(parsed) || !("mcp" in parsed)) { + continue; + } + if (!isRecord(parsed.mcp)) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + if (Object.keys(parsed.mcp).some((key) => nativeKeys.has(key))) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + } + + async #deleteManagedDocument( + request: McpConfigurationSnapshotRequest, + state: ManagedDocumentState | undefined, + observedBytes: Uint8Array | undefined, + desiredFingerprint: string, + receipt: McpConfigurationReceipt, + targetPath: string, + ): Promise { + if (observedBytes === undefined && state === undefined) { + return receipt; + } + if (observedBytes === undefined) { + await this.#commitState(request.agentTargetId, undefined); + return receipt; + } + await this.#prepareState(request, state, desiredFingerprint, true); + await this.#dependencies.fileSystem.removeFile(targetPath); + await this.#commitState(request.agentTargetId, undefined); + return receipt; + } + + async #readState( + agentTargetId: string, + ): Promise { + try { + return await this.#dependencies.state.read(agentTargetId); + } catch { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + + async #prepareState( + request: McpConfigurationSnapshotRequest, + state: ManagedDocumentState | undefined, + desiredFingerprint: string, + deleting: boolean, + ): Promise { + try { + await this.#dependencies.state.write(request.agentTargetId, { + applied: state?.applied, + prepared: { + operationId: request.operationId, + desiredFingerprint, + previousFingerprint: state?.applied?.fingerprint, + deleting, + }, + }); + } catch { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + + async #commitState( + agentTargetId: string, + fingerprint: string | undefined, + ): Promise { + try { + await this.#dependencies.state.write(agentTargetId, { + applied: fingerprint === undefined ? undefined : { fingerprint }, + prepared: undefined, + }); + } catch { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } +} + +/** Creates the production adapter while leaving private storage ownership at the callsite. */ +export function createOpenCodeMcpMaterializer( + state: ManagedStateStore, +): OpenCodeMcpMaterializer { + const fileSystem = new DenoMaterializationFileSystem(); + const atomicReplacer = new DenoAtomicReplacer(); + return new OpenCodeMcpMaterializer({ + fileSystem, + permissions: new CurrentUserPermissionRestrictor(), + atomicReplacer, + git: new RepositoryLocalGitGuard(fileSystem, atomicReplacer), + state, + }); +} + +function renderDocument(plans: readonly PlannedEntry[]): Uint8Array { + if (plans.length === 0) { + return new Uint8Array(); + } + const mcp = Object.fromEntries( + plans.map((entry) => [entry.nativeKey, entry.value]), + ); + return encoder.encode( + `${JSON.stringify({ $schema: OPEN_CODE_SCHEMA, mcp }, null, 2)}\n`, + ); +} + +function receiptFor( + request: McpConfigurationSnapshotRequest, + documentFingerprint: string, + plans: readonly PlannedEntry[], +): McpConfigurationReceipt { + return { + appliedGeneration: request.generation, + documentLocator: MANAGED_DOCUMENT_LOCATOR, + documentFingerprint, + entries: plans.map((plan) => plan.receipt), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/mcp/native-key.ts b/src/mcp/native-key.ts new file mode 100644 index 0000000..bc93fdc --- /dev/null +++ b/src/mcp/native-key.ts @@ -0,0 +1,53 @@ +import { McpMaterializationError } from "./errors.ts"; +import { sha256Hex } from "./fingerprint.ts"; + +export type IdentityDigest = (canonicalIdentity: string) => Promise; + +const NATIVE_KEY = /^ora_[a-z0-9_-]{0,48}_[a-f0-9]{12}$/; +const MAX_NATIVE_KEY_LENGTH = 65; + +/** + * Derives the version-independent OpenCode key mandated by MCP Configuration protocol v1. + * + * The digest is computed from the unmodified identity so truncation and punctuation folding do + * not silently merge identities. The injectable digest is solely a deterministic collision-test + * seam; production always uses SHA-256. + */ +export async function nativeMcpKey( + canonicalIdentity: string, + digest: IdentityDigest = sha256Hex, +): Promise { + if ( + canonicalIdentity.length === 0 || + canonicalIdentity !== canonicalIdentity.toLowerCase() + ) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + const readable = canonicalIdentity + .replaceAll(/[^a-z0-9_-]+/g, "_") + .replaceAll(/^_+|_+$/g, "") + .slice(0, 48); + const fullDigest = await digest(canonicalIdentity); + if (!/^[a-f0-9]{64}$/.test(fullDigest)) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + const key = `ora_${readable}_${fullDigest.slice(0, 12)}`; + if (!NATIVE_KEY.test(key) || key.length > MAX_NATIVE_KEY_LENGTH) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + return key; +} + +/** Rejects the final key map if two distinct identities ever defeat digest disambiguation. */ +export function assertNoNativeKeyCollisions( + entries: readonly { canonicalIdentity: string; nativeKey: string }[], +): void { + const identitiesByKey = new Map(); + for (const entry of entries) { + const existing = identitiesByKey.get(entry.nativeKey); + if (existing !== undefined && existing !== entry.canonicalIdentity) { + throw new McpMaterializationError("mcp_native_key_collision"); + } + identitiesByKey.set(entry.nativeKey, entry.canonicalIdentity); + } +} diff --git a/tests/fixtures/mcp-configuration/receipts/valid.json b/tests/fixtures/mcp-configuration/receipts/valid.json new file mode 100644 index 0000000..c85a2ca --- /dev/null +++ b/tests/fixtures/mcp-configuration/receipts/valid.json @@ -0,0 +1,13 @@ +{ + "appliedGeneration": 4, + "documentLocator": ".opencode/opencode.json", + "documentFingerprint": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "entries": [ + { + "managedIdentity": "mcp-tavily", + "nativeKey": "ora_tavily_search_abcdef123456", + "entryFingerprint": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sourceRevisionId": "rev-tavily-1" + } + ] +} diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index c6327e6..cc50ead 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -140,7 +140,7 @@ async function waitFor( * stuck step is worth more here than tidily unwinding the stream. */ function withStepTimeout(pending: Promise, label: string): Promise { - let timer: number | undefined; + let timer: ReturnType | undefined; const expiry = new Promise((_, reject) => { timer = setTimeout( () => @@ -352,6 +352,26 @@ if (effectSurfaces.length === 0) { } console.log(`ok: effectSurfaces ${JSON.stringify(effectSurfaces)}`); +const mcpConfiguration = + (register.params as { mcpConfiguration?: unknown } | undefined) + ?.mcpConfiguration; +if ( + JSON.stringify(mcpConfiguration) !== + JSON.stringify({ + protocolVersion: 1, + transports: ["http"], + coordination: "wait_for_idle_and_restart", + }) +) { + throw new Error("registration did not declare HTTP-only MCP protocol v1"); +} +const methods = + (register.params as { methods?: unknown[] } | undefined)?.methods ?? []; +if (!methods.includes("agent/configureWorkspace")) { + throw new Error("registration did not pair MCP capability with its handler"); +} +console.log("ok: HTTP-only MCP Configuration protocol v1"); + await send({ jsonrpc: "2.0", id: 1, diff --git a/tests/mcp-materializer.test.ts b/tests/mcp-materializer.test.ts new file mode 100644 index 0000000..750862f --- /dev/null +++ b/tests/mcp-materializer.test.ts @@ -0,0 +1,589 @@ +import type { + McpConfigurationSnapshotRequest, + SnapshotResolvedMcp, +} from "@ora-space/plugin-sdk"; +import { join } from "@std/path"; +import { McpMaterializationError } from "../src/mcp/errors.ts"; +import { + type AtomicReplacer, + DenoAtomicReplacer, + DenoMaterializationFileSystem, + type PermissionRestrictor, +} from "../src/mcp/filesystem.ts"; +import { fingerprintBytes } from "../src/mcp/fingerprint.ts"; +import { + type GitWorkspaceGuard, + RepositoryLocalGitGuard, +} from "../src/mcp/git.ts"; +import type { + ManagedDocumentState, + ManagedStateStore, +} from "../src/mcp/ledger.ts"; +import { + MANAGED_DOCUMENT_LOCATOR, + OpenCodeMcpMaterializer, +} from "../src/mcp/materializer.ts"; +import { + assertNoNativeKeyCollisions, + nativeMcpKey, +} from "../src/mcp/native-key.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +class MemoryStateStore implements ManagedStateStore { + readonly values = new Map(); + + read(agentTargetId: string): Promise { + return Promise.resolve(structuredClone(this.values.get(agentTargetId))); + } + + write(agentTargetId: string, state: ManagedDocumentState): Promise { + this.values.set(agentTargetId, structuredClone(state)); + return Promise.resolve(); + } +} + +class NoopPermissions implements PermissionRestrictor { + restrict(_path: string): Promise { + return Promise.resolve(); + } +} + +class RecordingPermissions implements PermissionRestrictor { + calls = 0; + + restrict(_path: string): Promise { + this.calls += 1; + return Promise.resolve(); + } +} + +class CommitFailingStateStore extends MemoryStateStore { + failCommit = true; + #writes = 0; + + override write( + agentTargetId: string, + state: ManagedDocumentState, + ): Promise { + this.#writes += 1; + if (this.failCommit && this.#writes === 2) { + throw new Error("injected ledger commit failure"); + } + return super.write(agentTargetId, state); + } +} + +class NoopGit implements GitWorkspaceGuard { + prepare(_workspaceRoot: string, _managedPath: string): Promise { + return Promise.resolve(); + } +} + +class FailingPermissions implements PermissionRestrictor { + restrict(_path: string): Promise { + throw new McpMaterializationError("mcp_config_permissions_failed"); + } +} + +class FailingGit implements GitWorkspaceGuard { + prepare(_workspaceRoot: string, _managedPath: string): Promise { + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } +} + +class FailingAtomicReplacer implements AtomicReplacer { + replace(_stagingPath: string, _targetPath: string): Promise { + throw new Error("injected atomic replacement failure"); + } +} + +function tavily( + authorization = "Bearer tavily-secret-key", +): SnapshotResolvedMcp { + return { + canonicalIdentity: "official/ora-space.tavily-search", + managedIdentity: "mcp-tavily", + packageVersion: "0.1.0", + sourceRevisionId: "rev-tavily-1", + transport: { + kind: "http", + url: "https://mcp.tavily.com/mcp", + headers: { Authorization: authorization }, + }, + }; +} + +function snapshot( + workspaceRoot: string, + resolvedMcps: readonly SnapshotResolvedMcp[] = [tavily()], + operationId = "op-7", + generation = 4, +): McpConfigurationSnapshotRequest { + return { + protocolVersion: 1, + operationId, + agentTargetId: "target-1", + workspaceRoot, + generation, + resolvedMcps, + }; +} + +function materializer( + state = new MemoryStateStore(), + overrides: { + permissions?: PermissionRestrictor; + git?: GitWorkspaceGuard; + atomicReplacer?: AtomicReplacer; + } = {}, +): OpenCodeMcpMaterializer { + const fs = new DenoMaterializationFileSystem(); + return new OpenCodeMcpMaterializer({ + fileSystem: fs, + permissions: overrides.permissions ?? new NoopPermissions(), + atomicReplacer: overrides.atomicReplacer ?? new DenoAtomicReplacer(), + git: overrides.git ?? new NoopGit(), + state, + }); +} + +async function withWorkspace( + run: (workspaceRoot: string) => Promise, +): Promise { + const workspaceRoot = await Deno.makeTempDir({ prefix: "ora-mcp-" }); + try { + await run(workspaceRoot); + } finally { + await Deno.remove(workspaceRoot, { recursive: true }); + } +} + +async function readManaged(workspaceRoot: string): Promise { + return await Deno.readFile(join(workspaceRoot, ".opencode", "opencode.json")); +} + +async function expectedTavilyDocument(): Promise { + const key = await nativeMcpKey(tavily().canonicalIdentity); + return encoder.encode(`${ + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + mcp: { + [key]: { + type: "remote", + url: "https://mcp.tavily.com/mcp", + enabled: true, + oauth: false, + headers: { Authorization: "Bearer tavily-secret-key" }, + }, + }, + }, + null, + 2, + ) + }\n`); +} + +Deno.test("Tavily materializes exact deterministic bytes and complete receipts", async () => { + await withWorkspace(async (workspaceRoot) => { + const rootConfig = encoder.encode('{"theme":"user-owned"}\n'); + await Deno.writeFile(join(workspaceRoot, "opencode.json"), rootConfig); + const state = new MemoryStateStore(); + const permissions = new RecordingPermissions(); + const adapter = materializer(state, { permissions }); + const request = snapshot(workspaceRoot); + const receipt = await adapter.configureWorkspace(request); + const bytes = await readManaged(workspaceRoot); + const expectedBytes = await expectedTavilyDocument(); + assertEquals([...bytes], [...expectedBytes]); + assertEquals( + [...await Deno.readFile(join(workspaceRoot, "opencode.json"))], + [...rootConfig], + ); + + const key = await nativeMcpKey(tavily().canonicalIdentity); + const entry = JSON.parse(decoder.decode(bytes)).mcp[key]; + assertEquals(receipt, { + appliedGeneration: 4, + documentLocator: MANAGED_DOCUMENT_LOCATOR, + documentFingerprint: await fingerprintBytes(expectedBytes), + entries: [{ + managedIdentity: "mcp-tavily", + nativeKey: key, + entryFingerprint: await fingerprintBytes( + encoder.encode(JSON.stringify(entry)), + ), + sourceRevisionId: "rev-tavily-1", + }], + }); + assert( + /^sha256:[a-f0-9]{64}$/.test(receipt.documentFingerprint), + "document fingerprint", + ); + const sharedReceiptFixture = JSON.parse( + await Deno.readTextFile( + new URL( + "./fixtures/mcp-configuration/receipts/valid.json", + import.meta.url, + ), + ), + ); + assertEquals(Object.keys(receipt), Object.keys(sharedReceiptFixture)); + assertEquals( + Object.keys(receipt.entries[0]), + Object.keys(sharedReceiptFixture.entries[0]), + ); + assertEquals(permissions.calls, 1); + + // The same operation and snapshot is a byte-for-byte no-op with the same complete receipt. + assertEquals(await adapter.configureWorkspace(request), receipt); + assertEquals([...await readManaged(workspaceRoot)], [...expectedBytes]); + + // A configuration revision changes bytes and receipts without changing the native key. + const updated = await adapter.configureWorkspace( + snapshot(workspaceRoot, [tavily("Bearer rotated-secret")], "op-8", 5), + ); + assertEquals(updated.entries[0].nativeKey, receipt.entries[0].nativeKey); + assert( + decoder.decode(await readManaged(workspaceRoot)).includes( + "rotated-secret", + ), + "updated bytes", + ); + assertEquals(permissions.calls, 2); + }); +}); + +Deno.test("a completed write before ledger commit is recoverable only by the same operation", async () => { + await withWorkspace(async (workspaceRoot) => { + const state = new CommitFailingStateStore(); + const request = snapshot(workspaceRoot); + const firstError = await captureFailure( + materializer(state).configureWorkspace(request), + ); + assertEquals(firstError.code, "mcp_materialization_conflict"); + assertEquals( + [...await readManaged(workspaceRoot)], + [...await expectedTavilyDocument()], + ); + assertEquals(state.values.get("target-1")?.prepared?.operationId, "op-7"); + + const differentOperation = await captureFailure( + materializer(state).configureWorkspace( + snapshot(workspaceRoot, [tavily()], "op-different"), + ), + ); + assertEquals(differentOperation.code, "mcp_materialization_conflict"); + + state.failCommit = false; + const recovered = await materializer(state).configureWorkspace(request); + assertEquals( + recovered.documentFingerprint, + await fingerprintBytes(await expectedTavilyDocument()), + ); + assertEquals(state.values.get("target-1")?.prepared, undefined); + }); +}); + +Deno.test("native keys are stable, bounded, character-safe, and collision checked", async () => { + const identity = "official/ora-space.tavily-search"; + const key = await nativeMcpKey(identity); + assertEquals(key, "ora_official_ora-space_tavily-search_d6968c95d917"); + assertEquals(await nativeMcpKey(identity), key); + const longKey = await nativeMcpKey(`official/${"punctuation.".repeat(10)}`); + assert(longKey.length <= 65, "native key length"); + assert(/^[a-z0-9_-]+$/.test(longKey), "native key character set"); + + assertEquals( + await nativeMcpKey(identity, () => Promise.resolve("a".repeat(64))), + "ora_official_ora-space_tavily-search_aaaaaaaaaaaa", + ); + let collision: unknown; + try { + assertNoNativeKeyCollisions([ + { canonicalIdentity: "one", nativeKey: "ora_same_aaaaaaaaaaaa" }, + { canonicalIdentity: "two", nativeKey: "ora_same_aaaaaaaaaaaa" }, + ]); + } catch (error) { + collision = error; + } + assertEquals( + (collision as McpMaterializationError).code, + "mcp_native_key_collision", + ); +}); + +Deno.test("entry ordering and fingerprints do not depend on snapshot order", async () => { + const other: SnapshotResolvedMcp = { + canonicalIdentity: "official/alpha", + managedIdentity: "mcp-alpha", + packageVersion: "9.9.9", + sourceRevisionId: "rev-alpha", + transport: { + kind: "http", + url: "https://alpha.example/mcp", + headers: { Zeta: "z", Alpha: "a" }, + }, + }; + let firstBytes: Uint8Array | undefined; + let firstFingerprint: string | undefined; + await withWorkspace(async (workspaceRoot) => { + const receipt = await materializer().configureWorkspace( + snapshot(workspaceRoot, [tavily(), other]), + ); + firstBytes = await readManaged(workspaceRoot); + firstFingerprint = receipt.documentFingerprint; + }); + await withWorkspace(async (workspaceRoot) => { + const receipt = await materializer().configureWorkspace( + snapshot(workspaceRoot, [other, tavily()]), + ); + assertEquals([...await readManaged(workspaceRoot)], [...firstBytes!]); + assertEquals(receipt.documentFingerprint, firstFingerprint); + assertEquals(receipt.entries.map((entry) => entry.nativeKey), [ + await nativeMcpKey(other.canonicalIdentity), + await nativeMcpKey(tavily().canonicalIdentity), + ]); + }); +}); + +Deno.test("root JSONC native-key collisions block without modifying user state", async () => { + await withWorkspace(async (workspaceRoot) => { + const key = await nativeMcpKey(tavily().canonicalIdentity); + const rootBytes = encoder.encode(`{ + // Preserved user configuration + "mcp": { "${key}": { "type": "remote", }, }, + }\n`); + await Deno.writeFile(join(workspaceRoot, "opencode.jsonc"), rootBytes); + const error = await captureFailure( + materializer().configureWorkspace(snapshot(workspaceRoot)), + ); + assertEquals(error.code, "mcp_materialization_conflict"); + assertEquals([ + ...await Deno.readFile(join(workspaceRoot, "opencode.jsonc")), + ], [...rootBytes]); + assertEquals( + await pathExists(join(workspaceRoot, ".opencode", "opencode.json")), + false, + ); + }); +}); + +Deno.test("an existing managed-path document is never adopted", async () => { + await withWorkspace(async (workspaceRoot) => { + const managedDirectory = join(workspaceRoot, ".opencode"); + await Deno.mkdir(managedDirectory); + const existing = encoder.encode('{"mcp":{"user":{}}}\n'); + const target = join(managedDirectory, "opencode.json"); + await Deno.writeFile(target, existing); + const error = await captureFailure( + materializer().configureWorkspace(snapshot(workspaceRoot)), + ); + assertEquals(error.code, "mcp_materialization_conflict"); + assertEquals([...await Deno.readFile(target)], [...existing]); + }); +}); + +Deno.test("Git workspaces receive only the exact local exclude and reject tracked paths", async () => { + await withWorkspace(async (workspaceRoot) => { + await git(workspaceRoot, "init"); + const fs = new DenoMaterializationFileSystem(); + const replacer = new DenoAtomicReplacer(); + const state = new MemoryStateStore(); + const adapter = new OpenCodeMcpMaterializer({ + fileSystem: fs, + permissions: new NoopPermissions(), + atomicReplacer: replacer, + git: new RepositoryLocalGitGuard(fs, replacer), + state, + }); + await adapter.configureWorkspace(snapshot(workspaceRoot)); + const excludePath = + (await git(workspaceRoot, "rev-parse", "--git-path", "info/exclude")) + .trim(); + const exclude = await Deno.readTextFile( + /^([A-Za-z]:[\\/]|\/)/.test(excludePath) + ? excludePath + : join(workspaceRoot, excludePath), + ); + assertEquals( + exclude.split(/\r?\n/).filter((line) => + line === "/.opencode/opencode.json" + ), + ["/.opencode/opencode.json"], + ); + assert( + !exclude.split(/\r?\n/).includes("/.opencode"), + "directory must not be ignored", + ); + assertEquals(await pathExists(join(workspaceRoot, ".gitignore")), false); + + await git(workspaceRoot, "add", "-f", ".opencode/opencode.json"); + const changed = tavily("Bearer rotated-secret"); + const error = await captureFailure( + adapter.configureWorkspace(snapshot(workspaceRoot, [changed], "op-8", 5)), + ); + assertEquals(error.code, "mcp_config_file_tracked"); + }); +}); + +Deno.test("Git exclude and permission failures leave no plaintext document or staging file", async () => { + for ( + const overrides of [ + { git: new FailingGit() }, + { permissions: new FailingPermissions() }, + ] + ) { + await withWorkspace(async (workspaceRoot) => { + const error = await captureFailure( + materializer(new MemoryStateStore(), overrides).configureWorkspace( + snapshot(workspaceRoot), + ), + ); + assert( + error.code === "mcp_config_git_exclude_failed" || + error.code === "mcp_config_permissions_failed", + "stable preparation failure", + ); + assertEquals( + await pathExists(join(workspaceRoot, ".opencode", "opencode.json")), + false, + ); + const names = await directoryNames(join(workspaceRoot, ".opencode")); + assertEquals(names.filter((name) => name.endsWith(".tmp")), []); + }); + } +}); + +Deno.test("atomic replacement failure preserves the prior committed document", async () => { + await withWorkspace(async (workspaceRoot) => { + const state = new MemoryStateStore(); + const first = materializer(state); + await first.configureWorkspace(snapshot(workspaceRoot)); + const committed = await readManaged(workspaceRoot); + const replacement = materializer(state, { + atomicReplacer: new FailingAtomicReplacer(), + }); + const error = await captureFailure(replacement.configureWorkspace( + snapshot(workspaceRoot, [tavily("Bearer rotated-secret")], "op-8", 5), + )); + assertEquals(error.code, "mcp_materialization_conflict"); + assertEquals([...await readManaged(workspaceRoot)], [...committed]); + assertEquals( + (await directoryNames(join(workspaceRoot, ".opencode"))).filter((name) => + name.endsWith(".tmp") + ), + [], + ); + }); +}); + +Deno.test("last-MCP deletion requires the applied fingerprint and preserves neighbors", async () => { + await withWorkspace(async (workspaceRoot) => { + const state = new MemoryStateStore(); + const adapter = materializer(state); + await adapter.configureWorkspace(snapshot(workspaceRoot)); + const neighbor = join(workspaceRoot, ".opencode", "neighbor.txt"); + await Deno.writeTextFile(neighbor, "preserve me"); + const deleteReceipt = await adapter.configureWorkspace( + snapshot(workspaceRoot, [], "op-delete", 5), + ); + assertEquals(deleteReceipt.entries, []); + assertEquals( + deleteReceipt.documentFingerprint, + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + assertEquals( + await pathExists(join(workspaceRoot, ".opencode", "opencode.json")), + false, + ); + assertEquals(await Deno.readTextFile(neighbor), "preserve me"); + assertEquals(await pathExists(join(workspaceRoot, ".opencode")), true); + }); + + await withWorkspace(async (workspaceRoot) => { + const state = new MemoryStateStore(); + const adapter = materializer(state); + await adapter.configureWorkspace(snapshot(workspaceRoot)); + const target = join(workspaceRoot, ".opencode", "opencode.json"); + await Deno.writeTextFile(target, '{"externally":"changed"}\n'); + const error = await captureFailure( + adapter.configureWorkspace(snapshot(workspaceRoot, [], "op-delete", 5)), + ); + assertEquals(error.code, "mcp_materialization_conflict"); + assertEquals(await Deno.readTextFile(target), '{"externally":"changed"}\n'); + }); +}); + +Deno.test("errors contain neither authorization values nor resolved URLs", async () => { + await withWorkspace(async (workspaceRoot) => { + const error = await captureFailure( + materializer(new MemoryStateStore(), { + permissions: new FailingPermissions(), + }).configureWorkspace(snapshot(workspaceRoot)), + ); + const rendered = JSON.stringify(error); + assert(!rendered.includes("tavily-secret-key"), "API key leaked"); + assert(!rendered.includes("Authorization"), "header name leaked"); + assert(!rendered.includes("mcp.tavily.com"), "resolved URL leaked"); + }); +}); + +async function captureFailure( + operation: Promise, +): Promise { + try { + await operation; + } catch (error) { + if (error instanceof McpMaterializationError) return error; + throw error; + } + throw new Error("expected materialization to fail"); +} + +async function pathExists(path: string): Promise { + try { + await Deno.lstat(path); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +} + +async function directoryNames(path: string): Promise { + try { + const names: string[] = []; + for await (const entry of Deno.readDir(path)) names.push(entry.name); + return names.sort(); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return []; + throw error; + } +} + +async function git(workspaceRoot: string, ...args: string[]): Promise { + const result = await new Deno.Command("git", { + args: ["-C", workspaceRoot, ...args], + stdout: "piped", + stderr: "piped", + }).output(); + if (!result.success) throw new Error("temporary Git command failed"); + return decoder.decode(result.stdout); +} diff --git a/tests/mcp-registration.test.ts b/tests/mcp-registration.test.ts new file mode 100644 index 0000000..c6ae7ff --- /dev/null +++ b/tests/mcp-registration.test.ts @@ -0,0 +1,94 @@ +import { defineAgent, type JsonValue } from "@ora-space/plugin-sdk"; +import { defineOpenCodeMcpConfiguration } from "../src/mcp/definition.ts"; +import { + DenoAtomicReplacer, + DenoMaterializationFileSystem, +} from "../src/mcp/filesystem.ts"; +import type { ManagedStateStore } from "../src/mcp/ledger.ts"; +import { OpenCodeMcpMaterializer } from "../src/mcp/materializer.ts"; + +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} + +/** Exercises the public SDK definition so capability and handler are observed on one handshake. */ +Deno.test("OpenCode registers protocol-v1 HTTP MCP support through the high-level SDK API", async () => { + const input = new TransformStream(); + const output = new TransformStream(); + const transport = { + readable: input.readable, + writable: output.writable, + redirectConsole: false, + }; + const plugin = defineAgent({ + start: () => {}, + stop: () => {}, + listModels: () => [], + onAcp: () => {}, + mcpConfiguration: defineOpenCodeMcpConfiguration(() => testMaterializer()), + }); + const running = plugin.run(transport); + const reader = output.readable.getReader(); + const registration = decodeFrame((await reader.read()).value!); + assertEquals(registration, { + jsonrpc: "2.0", + method: "ora/register", + params: { + methods: [ + "agent/start", + "agent/stop", + "agent/listModels", + "agent/configureWorkspace", + ], + emits: ["agent/acp"], + mcpConfiguration: { + protocolVersion: 1, + transports: ["http"], + coordination: "wait_for_idle_and_restart", + }, + }, + }); + reader.releaseLock(); + const writer = input.writable.getWriter(); + await writer.write(encodeFrame({ jsonrpc: "2.0", method: "ora/shutdown" })); + await writer.close(); + await running; +}); + +function testMaterializer(): OpenCodeMcpMaterializer { + const fileSystem = new DenoMaterializationFileSystem(); + return new OpenCodeMcpMaterializer({ + fileSystem, + permissions: { restrict: () => Promise.resolve() }, + atomicReplacer: new DenoAtomicReplacer(), + git: { prepare: () => Promise.resolve() }, + state: { + read: () => Promise.resolve(undefined), + write: () => Promise.resolve(), + } satisfies ManagedStateStore, + }); +} + +function encodeFrame(message: JsonValue): Uint8Array { + const payload = new TextEncoder().encode(JSON.stringify(message)); + const frame = new Uint8Array(payload.length + 5); + new DataView(frame.buffer).setUint32(0, payload.length + 1, false); + frame[4] = 1; + frame.set(payload, 5); + return frame; +} + +function decodeFrame(frame: Uint8Array): unknown { + const length = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) + .getUint32(0, false); + if (frame[4] !== 1 || length + 4 !== frame.byteLength) { + throw new Error("invalid test frame"); + } + return JSON.parse(new TextDecoder().decode(frame.slice(5))); +} From 4f814108d9c8b3df6115b4d066b4824de927415e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:03:29 +0800 Subject: [PATCH 2/4] fix(mcp): close materialization review gaps (#488) --- src/mcp/filesystem.ts | 46 +++++++++++--- src/mcp/git.ts | 20 ++++++- src/mcp/ledger.ts | 5 +- src/mcp/materializer.ts | 78 ++++++++++++++++++++---- tests/mcp-materializer.test.ts | 106 ++++++++++++++++++++++++++++++++- 5 files changed, 231 insertions(+), 24 deletions(-) diff --git a/src/mcp/filesystem.ts b/src/mcp/filesystem.ts index 9093223..5ad6659 100644 --- a/src/mcp/filesystem.ts +++ b/src/mcp/filesystem.ts @@ -9,7 +9,8 @@ export interface MaterializationFileSystem { ): Promise; read(path: string): Promise; ensureDirectory(path: string): Promise; - createStagingFile(targetPath: string, bytes: Uint8Array): Promise; + createStagingFile(targetPath: string): Promise; + writeStagingFile(stagingPath: string, bytes: Uint8Array): Promise; removeFile(path: string): Promise; cleanup(path: string): Promise; } @@ -27,6 +28,7 @@ export interface AtomicReplacer { /** Production Deno filesystem implementation with exclusive same-directory staging files. */ export class DenoMaterializationFileSystem implements MaterializationFileSystem { + /** Rejects link redirection before any managed-path read can be mistaken for owned state. */ async assertSafeManagedPaths( directoryPath: string, targetPath: string, @@ -63,10 +65,8 @@ export class DenoMaterializationFileSystem await Deno.mkdir(path, { recursive: true, mode: 0o700 }); } - async createStagingFile( - targetPath: string, - bytes: Uint8Array, - ): Promise { + /** Creates an empty exclusive staging inode so Windows ACLs can be set before plaintext. */ + async createStagingFile(targetPath: string): Promise { const stagingPath = join( dirname(targetPath), `.opencode.json.ora-${crypto.randomUUID()}.tmp`, @@ -76,8 +76,25 @@ export class DenoMaterializationFileSystem write: true, mode: 0o600, }); + file.close(); + return stagingPath; + } + + /** Writes and syncs every desired byte after the caller has restricted the staging inode. */ + async writeStagingFile( + stagingPath: string, + bytes: Uint8Array, + ): Promise { + const file = await Deno.open(stagingPath, { write: true, truncate: true }); try { - await file.write(bytes); + let offset = 0; + while (offset < bytes.length) { + const written = await file.write(bytes.subarray(offset)); + if (written === 0) { + throw new Error("staging write made no progress"); + } + offset += written; + } await file.sync(); } catch (error) { file.close(); @@ -85,11 +102,11 @@ export class DenoMaterializationFileSystem throw error; } file.close(); - return stagingPath; } async removeFile(path: string): Promise { await Deno.remove(path); + await syncDirectory(dirname(path)); } async cleanup(path: string): Promise { @@ -137,5 +154,20 @@ export class DenoAtomicReplacer implements AtomicReplacer { } finally { committed.close(); } + await syncDirectory(dirname(targetPath)); + } +} + +/** Syncs directory metadata where the OS exposes it so rename/delete survives a normal crash. */ +async function syncDirectory(path: string): Promise { + if (Deno.build.os === "windows") { + // Windows does not expose directory handles through Deno; MoveFileEx is its commit boundary. + return; + } + const directory = await Deno.open(path, { read: true }); + try { + await directory.sync(); + } finally { + directory.close(); } } diff --git a/src/mcp/git.ts b/src/mcp/git.ts index 4432d8c..ede6a22 100644 --- a/src/mcp/git.ts +++ b/src/mcp/git.ts @@ -45,13 +45,14 @@ export class RepositoryLocalGitGuard implements GitWorkspaceGuard { this.#run = run; } + /** Completes tracked-path and exact-exclude checks before document staging may begin. */ async prepare(workspaceRoot: string, managedPath: string): Promise { const topLevel = await this.#run(workspaceRoot, [ "rev-parse", "--show-toplevel", ]); if (!topLevel.success) { - if (topLevel.code === 128) { + if (topLevel.code === 128 && await gitMarkerIsAbsent(workspaceRoot)) { return; } throw new McpMaterializationError("mcp_config_git_exclude_failed"); @@ -104,6 +105,7 @@ export class RepositoryLocalGitGuard implements GitWorkspaceGuard { await this.#ensureExactExclude(excludePath); } + /** Appends one exact line atomically so unrelated repository-local patterns remain byte-stable. */ async #ensureExactExclude(excludePath: string): Promise { try { const existing = await this.#fs.read(excludePath) ?? new Uint8Array(); @@ -116,8 +118,9 @@ export class RepositoryLocalGitGuard implements GitWorkspaceGuard { `${text}${separator}${MANAGED_EXCLUDE}\n`, ); await this.#fs.ensureDirectory(dirname(excludePath)); - const staging = await this.#fs.createStagingFile(excludePath, desired); + const staging = await this.#fs.createStagingFile(excludePath); try { + await this.#fs.writeStagingFile(staging, desired); await this.#replacer.replace(staging, excludePath); } finally { await this.#fs.cleanup(staging); @@ -131,6 +134,19 @@ export class RepositoryLocalGitGuard implements GitWorkspaceGuard { } } +/** Distinguishes a genuine non-Git Workspace from a broken or unsafe repository checkout. */ +async function gitMarkerIsAbsent(workspaceRoot: string): Promise { + try { + await Deno.lstat(join(workspaceRoot, ".git")); + return false; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return true; + } + throw new McpMaterializationError("mcp_config_git_exclude_failed"); + } +} + async function runGit( workspaceRoot: string, args: readonly string[], diff --git a/src/mcp/ledger.ts b/src/mcp/ledger.ts index b4dd0d9..8c2bb93 100644 --- a/src/mcp/ledger.ts +++ b/src/mcp/ledger.ts @@ -9,7 +9,7 @@ export interface PreparedManagedDocumentOperation { operationId: string; desiredFingerprint: string; previousFingerprint: string | undefined; - deleting: boolean; + intent: "replace" | "delete"; } export interface ManagedDocumentState { @@ -72,6 +72,7 @@ async function statePath(agentTargetId: string): Promise { return `mcp-managed-state/${fingerprint.slice(7)}.json`; } +/** Rejects ledger drift so malformed private state can never become proof of file ownership. */ function parseState(value: unknown): ManagedDocumentState { if (!isRecord(value) || value.schemaVersion !== 1) { throw new Error("invalid MCP managed-state ledger"); @@ -94,7 +95,7 @@ function parseState(value: unknown): ManagedDocumentState { prepared.previousFingerprint !== undefined && !isFingerprint(prepared.previousFingerprint) ) || - typeof prepared.deleting !== "boolean" + (prepared.intent !== "replace" && prepared.intent !== "delete") ) ) { throw new Error("invalid MCP managed-state ledger"); diff --git a/src/mcp/materializer.ts b/src/mcp/materializer.ts index 4be032a..4bcdf5b 100644 --- a/src/mcp/materializer.ts +++ b/src/mcp/materializer.ts @@ -45,6 +45,8 @@ interface PlannedEntry { receipt: McpEntryReceipt; } +type ManagedDocumentIntent = "replace" | "delete"; + /** Dependencies whose failure behavior is significant to the all-or-nothing write contract. */ export interface McpMaterializerDependencies { fileSystem: MaterializationFileSystem; @@ -81,10 +83,14 @@ export class OpenCodeMcpMaterializer { } } + /** Orders proof, preserved-state, Git, ledger, and atomic-write gates before reporting success. */ async #configureWorkspace( request: McpConfigurationSnapshotRequest, ): Promise { const plans = await this.#planEntries(request.resolvedMcps); + const intent: ManagedDocumentIntent = plans.length === 0 + ? "delete" + : "replace"; const documentBytes = renderDocument(plans); const documentFingerprint = await fingerprintBytes(documentBytes); const managedDirectory = join(request.workspaceRoot, ".opencode"); @@ -107,7 +113,7 @@ export class OpenCodeMcpMaterializer { state, observedFingerprint, documentFingerprint, - plans.length === 0, + intent, ); if (plans.length > 0) { @@ -133,6 +139,7 @@ export class OpenCodeMcpMaterializer { } if (observedFingerprint === documentFingerprint) { + await this.#assertObservedUnchanged(targetPath, observedFingerprint); await this.#commitState(request.agentTargetId, documentFingerprint); return receipt; } @@ -141,7 +148,7 @@ export class OpenCodeMcpMaterializer { request, state, documentFingerprint, - false, + "replace", ); await this.#dependencies.fileSystem.ensureDirectory(managedDirectory); await this.#dependencies.fileSystem.assertSafeManagedPaths( @@ -150,10 +157,15 @@ export class OpenCodeMcpMaterializer { ); const stagingPath = await this.#dependencies.fileSystem.createStagingFile( targetPath, - documentBytes, ); try { + // Windows ignores create mode, so restrict the empty file before plaintext can inherit ACLs. await this.#dependencies.permissions.restrict(stagingPath); + await this.#dependencies.fileSystem.writeStagingFile( + stagingPath, + documentBytes, + ); + await this.#assertObservedUnchanged(targetPath, observedFingerprint); await this.#dependencies.atomicReplacer.replace(stagingPath, targetPath); } finally { await this.#dependencies.fileSystem.cleanup(stagingPath); @@ -162,6 +174,7 @@ export class OpenCodeMcpMaterializer { return receipt; } + /** Plans the entire entry set first so one invalid MCP cannot leave a partially changed file. */ async #planEntries( resolvedMcps: readonly SnapshotResolvedMcp[], ): Promise { @@ -191,7 +204,7 @@ export class OpenCodeMcpMaterializer { oauth: false, headers: Object.fromEntries( Object.entries(mcp.transport.headers).sort(([left], [right]) => - left.localeCompare(right) + compareCodeUnits(left, right) ), ), }; @@ -211,7 +224,9 @@ export class OpenCodeMcpMaterializer { }, }; })); - plans.sort((left, right) => left.nativeKey.localeCompare(right.nativeKey)); + plans.sort((left, right) => + compareCodeUnits(left.nativeKey, right.nativeKey) + ); assertNoNativeKeyCollisions(plans); if ( new Set(plans.map((entry) => entry.managedIdentity)).size !== @@ -223,20 +238,31 @@ export class OpenCodeMcpMaterializer { return plans; } + /** Accepts bytes only when an applied ledger or this exact prepared replay proves ownership. */ #assertOwnership( request: McpConfigurationSnapshotRequest, state: ManagedDocumentState | undefined, observedFingerprint: string | undefined, desiredFingerprint: string, - deleting: boolean, + intent: ManagedDocumentIntent, ): void { + if ( + state?.prepared !== undefined && + ( + state.prepared.operationId !== request.operationId || + state.prepared.desiredFingerprint !== desiredFingerprint || + state.prepared.intent !== intent + ) + ) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } const appliedMatches = observedFingerprint !== undefined && state?.applied?.fingerprint === observedFingerprint; const preparedMatches = state?.prepared?.operationId === request.operationId && state.prepared.desiredFingerprint === desiredFingerprint && - state.prepared.deleting === deleting && - (deleting + state.prepared.intent === intent && + (intent === "delete" ? observedFingerprint === undefined : observedFingerprint === desiredFingerprint); @@ -253,6 +279,7 @@ export class OpenCodeMcpMaterializer { } } + /** Blocks ambiguous user-layer merges before Git state or managed bytes are changed. */ async #assertRootConfigurationPreserved( workspaceRoot: string, plans: readonly PlannedEntry[], @@ -285,6 +312,7 @@ export class OpenCodeMcpMaterializer { } } + /** Deletes only an owned, fingerprint-verified document while leaving its directory untouched. */ async #deleteManagedDocument( request: McpConfigurationSnapshotRequest, state: ManagedDocumentState | undefined, @@ -300,12 +328,31 @@ export class OpenCodeMcpMaterializer { await this.#commitState(request.agentTargetId, undefined); return receipt; } - await this.#prepareState(request, state, desiredFingerprint, true); + await this.#prepareState(request, state, desiredFingerprint, "delete"); + await this.#assertObservedUnchanged( + targetPath, + await fingerprintBytes(observedBytes), + ); await this.#dependencies.fileSystem.removeFile(targetPath); await this.#commitState(request.agentTargetId, undefined); return receipt; } + /** Rechecks the last observed fingerprint immediately before return, replace, or deletion. */ + async #assertObservedUnchanged( + targetPath: string, + expectedFingerprint: string | undefined, + ): Promise { + const currentBytes = await this.#dependencies.fileSystem.read(targetPath); + const currentFingerprint = currentBytes === undefined + ? undefined + : await fingerprintBytes(currentBytes); + if (currentFingerprint !== expectedFingerprint) { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + + /** Maps unavailable or malformed ownership state to a safe preserved-state conflict. */ async #readState( agentTargetId: string, ): Promise { @@ -316,11 +363,12 @@ export class OpenCodeMcpMaterializer { } } + /** Persists replay evidence before the first plaintext filesystem mutation. */ async #prepareState( request: McpConfigurationSnapshotRequest, state: ManagedDocumentState | undefined, desiredFingerprint: string, - deleting: boolean, + intent: ManagedDocumentIntent, ): Promise { try { await this.#dependencies.state.write(request.agentTargetId, { @@ -329,7 +377,7 @@ export class OpenCodeMcpMaterializer { operationId: request.operationId, desiredFingerprint, previousFingerprint: state?.applied?.fingerprint, - deleting, + intent, }, }); } catch { @@ -337,6 +385,7 @@ export class OpenCodeMcpMaterializer { } } + /** Advances applied ownership only after the filesystem side effect has committed. */ async #commitState( agentTargetId: string, fingerprint: string | undefined, @@ -367,6 +416,7 @@ export function createOpenCodeMcpMaterializer( }); } +/** Renders only the target-native fields Ora owns, with a single canonical trailing newline. */ function renderDocument(plans: readonly PlannedEntry[]): Uint8Array { if (plans.length === 0) { return new Uint8Array(); @@ -379,6 +429,7 @@ function renderDocument(plans: readonly PlannedEntry[]): Uint8Array { ); } +/** Mirrors every planned entry into the exact full-coverage receipt the Host validates. */ function receiptFor( request: McpConfigurationSnapshotRequest, documentFingerprint: string, @@ -392,6 +443,11 @@ function receiptFor( }; } +/** Uses locale-independent UTF-16 order so output bytes agree on every host locale. */ +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/tests/mcp-materializer.test.ts b/tests/mcp-materializer.test.ts index 750862f..f0e8daf 100644 --- a/tests/mcp-materializer.test.ts +++ b/tests/mcp-materializer.test.ts @@ -66,10 +66,11 @@ class NoopPermissions implements PermissionRestrictor { class RecordingPermissions implements PermissionRestrictor { calls = 0; + readonly sizesBeforeRestriction: number[] = []; - restrict(_path: string): Promise { + async restrict(path: string): Promise { this.calls += 1; - return Promise.resolve(); + this.sizesBeforeRestriction.push((await Deno.stat(path)).size); } } @@ -113,6 +114,21 @@ class FailingAtomicReplacer implements AtomicReplacer { } } +class MutatingReadFileSystem extends DenoMaterializationFileSystem { + targetPath = ""; + targetReads = 0; + + override async read(path: string): Promise { + if (path === this.targetPath) { + this.targetReads += 1; + if (this.targetReads === 2) { + await Deno.writeTextFile(path, '{"externally":"raced"}\n'); + } + } + return await super.read(path); + } +} + function tavily( authorization = "Bearer tavily-secret-key", ): SnapshotResolvedMcp { @@ -250,6 +266,7 @@ Deno.test("Tavily materializes exact deterministic bytes and complete receipts", Object.keys(sharedReceiptFixture.entries[0]), ); assertEquals(permissions.calls, 1); + assertEquals(permissions.sizesBeforeRestriction, [0]); // The same operation and snapshot is a byte-for-byte no-op with the same complete receipt. assertEquals(await adapter.configureWorkspace(request), receipt); @@ -267,6 +284,7 @@ Deno.test("Tavily materializes exact deterministic bytes and complete receipts", "updated bytes", ); assertEquals(permissions.calls, 2); + assertEquals(permissions.sizesBeforeRestriction, [0, 0]); }); }); @@ -400,6 +418,51 @@ Deno.test("an existing managed-path document is never adopted", async () => { }); }); +Deno.test("a non-Git Workspace skips exclude while retaining permission enforcement", async () => { + await withWorkspace(async (workspaceRoot) => { + const fs = new DenoMaterializationFileSystem(); + const replacer = new DenoAtomicReplacer(); + const permissions = new RecordingPermissions(); + const adapter = new OpenCodeMcpMaterializer({ + fileSystem: fs, + permissions, + atomicReplacer: replacer, + git: new RepositoryLocalGitGuard(fs, replacer), + state: new MemoryStateStore(), + }); + await adapter.configureWorkspace(snapshot(workspaceRoot)); + assertEquals(permissions.calls, 1); + assertEquals(permissions.sizesBeforeRestriction, [0]); + assertEquals(await pathExists(join(workspaceRoot, ".gitignore")), false); + }); +}); + +Deno.test("a broken Git marker blocks instead of masquerading as a non-Git Workspace", async () => { + await withWorkspace(async (workspaceRoot) => { + await Deno.writeTextFile( + join(workspaceRoot, ".git"), + "not a gitdir pointer\n", + ); + const fs = new DenoMaterializationFileSystem(); + const replacer = new DenoAtomicReplacer(); + const adapter = new OpenCodeMcpMaterializer({ + fileSystem: fs, + permissions: new NoopPermissions(), + atomicReplacer: replacer, + git: new RepositoryLocalGitGuard(fs, replacer), + state: new MemoryStateStore(), + }); + const error = await captureFailure( + adapter.configureWorkspace(snapshot(workspaceRoot)), + ); + assertEquals(error.code, "mcp_config_git_exclude_failed"); + assertEquals( + await pathExists(join(workspaceRoot, ".opencode", "opencode.json")), + false, + ); + }); +}); + Deno.test("Git workspaces receive only the exact local exclude and reject tracked paths", async () => { await withWorkspace(async (workspaceRoot) => { await git(workspaceRoot, "init"); @@ -485,6 +548,45 @@ Deno.test("atomic replacement failure preserves the prior committed document", a )); assertEquals(error.code, "mcp_materialization_conflict"); assertEquals([...await readManaged(workspaceRoot)], [...committed]); + const competingOperation = await captureFailure( + materializer(state).configureWorkspace( + snapshot( + workspaceRoot, + [tavily("Bearer another-secret")], + "op-competing", + 6, + ), + ), + ); + assertEquals(competingOperation.code, "mcp_materialization_conflict"); + assertEquals( + (await directoryNames(join(workspaceRoot, ".opencode"))).filter((name) => + name.endsWith(".tmp") + ), + [], + ); + }); +}); + +Deno.test("a last-moment external replacement blocks the atomic commit", async () => { + await withWorkspace(async (workspaceRoot) => { + const state = new MemoryStateStore(); + await materializer(state).configureWorkspace(snapshot(workspaceRoot)); + const target = join(workspaceRoot, ".opencode", "opencode.json"); + const fs = new MutatingReadFileSystem(); + fs.targetPath = target; + const adapter = new OpenCodeMcpMaterializer({ + fileSystem: fs, + permissions: new NoopPermissions(), + atomicReplacer: new DenoAtomicReplacer(), + git: new NoopGit(), + state, + }); + const error = await captureFailure(adapter.configureWorkspace( + snapshot(workspaceRoot, [tavily("Bearer rotated-secret")], "op-race", 5), + )); + assertEquals(error.code, "mcp_materialization_conflict"); + assertEquals(await Deno.readTextFile(target), '{"externally":"raced"}\n'); assertEquals( (await directoryNames(join(workspaceRoot, ".opencode"))).filter((name) => name.endsWith(".tmp") From 09c960b373c5c03dcb3a448f4d9ecde2dcc86de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:04:36 +0800 Subject: [PATCH 3/4] refactor(mcp): make ownership transitions explicit (#488) --- src/mcp/materializer.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/mcp/materializer.ts b/src/mcp/materializer.ts index 4bcdf5b..cc6523c 100644 --- a/src/mcp/materializer.ts +++ b/src/mcp/materializer.ts @@ -140,7 +140,10 @@ export class OpenCodeMcpMaterializer { if (observedFingerprint === documentFingerprint) { await this.#assertObservedUnchanged(targetPath, observedFingerprint); - await this.#commitState(request.agentTargetId, documentFingerprint); + await this.#commitAppliedState( + request.agentTargetId, + documentFingerprint, + ); return receipt; } @@ -170,7 +173,10 @@ export class OpenCodeMcpMaterializer { } finally { await this.#dependencies.fileSystem.cleanup(stagingPath); } - await this.#commitState(request.agentTargetId, documentFingerprint); + await this.#commitAppliedState( + request.agentTargetId, + documentFingerprint, + ); return receipt; } @@ -325,7 +331,7 @@ export class OpenCodeMcpMaterializer { return receipt; } if (observedBytes === undefined) { - await this.#commitState(request.agentTargetId, undefined); + await this.#clearOwnershipState(request.agentTargetId); return receipt; } await this.#prepareState(request, state, desiredFingerprint, "delete"); @@ -334,7 +340,7 @@ export class OpenCodeMcpMaterializer { await fingerprintBytes(observedBytes), ); await this.#dependencies.fileSystem.removeFile(targetPath); - await this.#commitState(request.agentTargetId, undefined); + await this.#clearOwnershipState(request.agentTargetId); return receipt; } @@ -385,14 +391,26 @@ export class OpenCodeMcpMaterializer { } } - /** Advances applied ownership only after the filesystem side effect has committed. */ - async #commitState( + /** Advances applied ownership only after the filesystem replacement has committed. */ + async #commitAppliedState( agentTargetId: string, - fingerprint: string | undefined, + fingerprint: string, ): Promise { try { await this.#dependencies.state.write(agentTargetId, { - applied: fingerprint === undefined ? undefined : { fingerprint }, + applied: { fingerprint }, + prepared: undefined, + }); + } catch { + throw new McpMaterializationError("mcp_materialization_conflict"); + } + } + + /** Clears ownership only after absence or a fingerprint-verified deletion is established. */ + async #clearOwnershipState(agentTargetId: string): Promise { + try { + await this.#dependencies.state.write(agentTargetId, { + applied: undefined, prepared: undefined, }); } catch { From 4325c893be288bb6ee84865c3c505ea9454bc3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:27:12 +0800 Subject: [PATCH 4/4] fix(mcp): reapply permissions on idempotent replay (#488) --- src/mcp/materializer.ts | 3 +++ tests/mcp-materializer.test.ts | 25 +++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/mcp/materializer.ts b/src/mcp/materializer.ts index cc6523c..5efdd60 100644 --- a/src/mcp/materializer.ts +++ b/src/mcp/materializer.ts @@ -140,6 +140,9 @@ export class OpenCodeMcpMaterializer { if (observedFingerprint === documentFingerprint) { await this.#assertObservedUnchanged(targetPath, observedFingerprint); + // Fingerprints prove byte ownership but cannot detect chmod or ACL drift. Reapplying the + // current-user-only policy keeps an idempotent replay from accepting exposed credentials. + await this.#dependencies.permissions.restrict(targetPath); await this.#commitAppliedState( request.agentTargetId, documentFingerprint, diff --git a/tests/mcp-materializer.test.ts b/tests/mcp-materializer.test.ts index f0e8daf..21a4dfb 100644 --- a/tests/mcp-materializer.test.ts +++ b/tests/mcp-materializer.test.ts @@ -283,8 +283,12 @@ Deno.test("Tavily materializes exact deterministic bytes and complete receipts", ), "updated bytes", ); - assertEquals(permissions.calls, 2); - assertEquals(permissions.sizesBeforeRestriction, [0, 0]); + assertEquals(permissions.calls, 3); + assertEquals(permissions.sizesBeforeRestriction, [ + 0, + expectedBytes.length, + 0, + ]); }); }); @@ -319,6 +323,23 @@ Deno.test("a completed write before ledger commit is recoverable only by the sam }); }); +Deno.test("an idempotent replay reapplies restrictive document permissions", async () => { + await withWorkspace(async (workspaceRoot) => { + const state = new MemoryStateStore(); + await materializer(state).configureWorkspace(snapshot(workspaceRoot)); + const permissions = new RecordingPermissions(); + const receipt = await materializer(state, { permissions }) + .configureWorkspace(snapshot(workspaceRoot)); + + assertEquals(receipt.appliedGeneration, 4); + assertEquals(permissions.calls, 1); + assert( + permissions.sizesBeforeRestriction[0] > 0, + "the committed document must be restricted on replay", + ); + }); +}); + Deno.test("native keys are stable, bounded, character-safe, and collision checked", async () => { const identity = "official/ora-space.tavily-search"; const key = await nativeMcpKey(identity);