diff --git a/README.md b/README.md index d5c8ddc..fc862bd 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,9 @@ ends of: a request injected into it would return its answer down Ora's pipe, and the client capabilities Ora declares in its own `initialize` are what decide whether OpenCode reports a model selector at all. -Answers are reused for five minutes per workspace, so a picker that re-renders does -not restart the CLI, while a model that appears after a provider login shows up -the next time the picker is opened. +Answers are reused for five minutes per workspace, so a picker that re-renders +does not restart the CLI, while a model that appears after a provider login +shows up the next time the picker is opened. ## Known limits @@ -157,3 +157,11 @@ the next time the picker is opened. up with; the five-minute cache is what keeps the count down. - Killing the CLI on agent stop is best effort; Ora retains process-tree reaping as a backstop. + +## Project Effects + +Ora manages `.opencode/skills` as a Skill Effect Resource. Configured MCP +plugins are not written into `.opencode/opencode.json`; Ora injects them through +ACP `session/new` and `session/load` `mcpServers`, which this plugin forwards +unchanged. Secret values stay in Ora's configuration store and never appear in +Workspace files, logs, or `ORA_MCP_*` environment variables. diff --git a/deno.json b/deno.json index 9bff359..c3e21f3 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@ora-space/opencode-agent", - "version": "0.1.0", + "version": "0.6.0", "exports": "./src/main.ts", "minimumDependencyAge": 0, "imports": { @@ -11,9 +11,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/acp-forward.test.ts", "lint": "deno lint src scripts tests bundle.config.ts", "format": "deno fmt src scripts tests bundle.config.ts deno.json README.md", + "test": "deno test --allow-read tests/acp-forward.test.ts", "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", "build": "deno bundle src/main.ts -o dist/main.js", diff --git a/deno.lock b/deno.lock index 4887a45..c13cca5 100644 --- a/deno.lock +++ b/deno.lock @@ -2,8 +2,10 @@ "version": "5", "specifiers": { "jsr:@ora-space/plugin-sdk@0.9.0": "0.9.0", + "jsr:@std/assert@1": "1.0.19", "jsr:@std/cli@^1.0.32": "1.0.32", "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/streams@^1.0.17": "1.1.2", @@ -14,11 +16,17 @@ "@ora-space/plugin-sdk@0.9.0": { "integrity": "667d812ea8ec87b0976f2bc6ed00e9915e7fa170e3f3793ad46e4087e36cdcfd" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/cli@1.0.32": { "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66", "dependencies": [ "jsr:@std/fmt", - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/fmt@1.0.10": { @@ -30,7 +38,7 @@ "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/streams@1.1.2": { diff --git a/src/handlers/acp.ts b/src/handlers/acp.ts index da34f44..dad8da8 100644 --- a/src/handlers/acp.ts +++ b/src/handlers/acp.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "@ora-space/plugin-sdk"; -import type { SkillEffectCoordinator } from "./effects.ts"; +import type { AgentEffectCoordinator } from "./effects.ts"; import type { OpenCodeClient } from "../services/opencode-client.ts"; /** @@ -16,7 +16,7 @@ import type { OpenCodeClient } from "../services/opencode-client.ts"; */ export function forwardAcpFrame( client: OpenCodeClient, - effects: SkillEffectCoordinator, + effects: AgentEffectCoordinator, frame: JsonValue, ): Promise | void { if (effects.intercept(frame)) { diff --git a/src/handlers/effects.ts b/src/handlers/effects.ts index b220eb1..7dd47ad 100644 --- a/src/handlers/effects.ts +++ b/src/handlers/effects.ts @@ -52,7 +52,7 @@ const QUIESCE_POLL_MS = 50; * what was held, and `verifyReady` reports whether the process Ora is about to mark ready is one * that has actually read the Skills on disk. */ -export class SkillEffectCoordinator { +export class AgentEffectCoordinator { readonly #client: OpenCodeClient; readonly #cwd: () => string | undefined; readonly #openTurns = new Set(); @@ -179,13 +179,13 @@ export class SkillEffectCoordinator { if (!this.#client.running) { throw new PluginMethodError( CONSUMER_NOT_READY, - "the OpenCode CLI is not running, so it has read no Skills", + "the OpenCode CLI is not running, so it has not read project Skills", ); } if (this.#held !== undefined) { throw new PluginMethodError( CONSUMER_NOT_READY, - "OpenCode is quiesced for a Skill mutation and has not rescanned yet", + "OpenCode is quiesced for a project Effect mutation and has not reloaded yet", ); } return { diff --git a/src/main.ts b/src/main.ts index f042d91..3176979 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,7 @@ import { runAgentPlugin, } from "./base/agent-plugin.ts"; import { forwardAcpFrame } from "./handlers/acp.ts"; -import { SkillEffectCoordinator } from "./handlers/effects.ts"; +import { AgentEffectCoordinator } from "./handlers/effects.ts"; import { startOpenCode, stopOpenCode } from "./handlers/lifecycle.ts"; import { invalidateAllOpenCodeModels, @@ -59,7 +59,7 @@ class OpenCodeAgentPlugin extends AgentPlugin { }, }); - readonly #effects = new SkillEffectCoordinator(this.#client, () => this.#cwd); + readonly #effects = new AgentEffectCoordinator(this.#client, () => this.#cwd); override readonly effects = this.#effects.definition; diff --git a/tests/acp-forward.test.ts b/tests/acp-forward.test.ts new file mode 100644 index 0000000..96a20de --- /dev/null +++ b/tests/acp-forward.test.ts @@ -0,0 +1,68 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import type { JsonValue } from "@ora-space/plugin-sdk"; +import { + AgentEffectCoordinator, + SKILLS_RESOURCE, +} from "../src/handlers/effects.ts"; +import { OpenCodeClient } from "../src/services/opencode-client.ts"; + +/** Secret-bearing stdio and HTTP servers the host injects through ACP. */ +function mcpServers(): JsonValue[] { + return [ + { + type: "stdio", + name: "ora-space/tavily-search", + command: "/pkg/assets/server", + args: ["."], + env: [{ name: "TAVILY_API_KEY", value: "super-secret" }], + }, + { + type: "http", + name: "ora-space/alpha-search", + url: "https://mcp.example.test/mcp", + headers: [{ name: "Authorization", value: "Bearer super-secret" }], + }, + ]; +} + +Deno.test("registers only the Skill Effect Resource", () => { + const effects = new AgentEffectCoordinator( + new OpenCodeClient(), + () => undefined, + ); + assertEquals(effects.definition.resources, [SKILLS_RESOURCE]); +}); + +Deno.test("does not intercept session/new mcpServers", () => { + const effects = new AgentEffectCoordinator( + new OpenCodeClient(), + () => undefined, + ); + const frame = { + jsonrpc: "2.0", + id: 2, + method: "session/new", + params: { cwd: "/workspace", mcpServers: mcpServers() }, + }; + assertEquals(effects.intercept(frame), false); + assertEquals(frame.params.mcpServers, mcpServers()); +}); + +Deno.test("does not intercept session/load mcpServers", () => { + const effects = new AgentEffectCoordinator( + new OpenCodeClient(), + () => undefined, + ); + const frame = { + jsonrpc: "2.0", + id: 3, + method: "session/load", + params: { + sessionId: "ses_1", + cwd: "/workspace", + mcpServers: mcpServers(), + }, + }; + assertEquals(effects.intercept(frame), false); + assertEquals(frame.params.mcpServers, mcpServers()); +}); diff --git a/tests/host-simulator.ts b/tests/host-simulator.ts index 65c50a7..01e0913 100644 --- a/tests/host-simulator.ts +++ b/tests/host-simulator.ts @@ -172,6 +172,7 @@ interface SimulatedChildProcess { const simulatedChildProcesses = new Map(); let nextChildProcessId = 1; +let agentProcessSpawns = 0; /** Recognizes a plugin-to-host request for `ora/childprocess/*`. */ function isChildProcessRequest( @@ -284,11 +285,28 @@ async function dispatchChildProcessMethod( const command = resolveSimulatedProgram(params); const args = (params.args as string[] | undefined) ?? []; const cwd = (params.cwd as string | null | undefined) ?? undefined; + const requestedEnvironment = (params.env ?? {}) as Record; + if ( + Object.keys(requestedEnvironment).some((key) => + key.startsWith("ORA_MCP_") + ) + ) { + throw new SimulatedSpawnError( + "invalid_params", + "reserved MCP environment", + ); + } + if (args.includes("acp")) { + agentProcessSpawns += 1; + } let child: Deno.ChildProcess; try { child = new Deno.Command(command, { args, cwd, + env: { + ...requestedEnvironment, + }, stdin: "piped", stdout: "piped", stderr: "piped", @@ -404,11 +422,23 @@ const register = await waitFor( ); console.log(`ok: register ${JSON.stringify(register.params)}`); -const effectResources = - (register.params as { effectResources?: unknown[] } | undefined) - ?.effectResources ?? []; -if (effectResources.length === 0) { - throw new Error("registration did not declare any Effect Resource"); +const effectResources = (register.params as + | { effectResources?: Record[] } + | undefined) + ?.effectResources ?? []; +const resourceSignatures = effectResources.map((resource) => + `${resource.workspaceRelativePath}:${resource.materializationFormat}` +).sort(); +const expectedResourceSignatures = [ + ".opencode/skills:ora/skill-directory.v1", +]; +if ( + JSON.stringify(resourceSignatures) !== + JSON.stringify(expectedResourceSignatures) +) { + throw new Error( + `unexpected Effect Resources: ${JSON.stringify(effectResources)}`, + ); } console.log(`ok: effectResources ${JSON.stringify(effectResources)}`); @@ -497,7 +527,7 @@ console.log( // from these, so any stable pair drives the same code the host would. const coordinationParams = { targetId: "sim-target", - resourceIds: ["sim-resource"], + resourceIds: ["sim-skills"], }; await send({ @@ -591,6 +621,11 @@ const sessionAfterRestart = await waitFor( (message) => message.method === "agent/acp" && acpFrame(message).id === 3, "ACP session/new after restart", ); +if (agentProcessSpawns !== 2) { + throw new Error( + `expected one initial spawn and one shared Effect restart, got ${agentProcessSpawns}`, + ); +} console.log( `ok: session/new after restart ${ JSON.stringify(