From a30d7fa26ffc618f53c3bc70bc4c50466af74d41 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:27:58 +0000 Subject: [PATCH 1/8] feat: add OpenCode v2 (opencode2) as a first-class client Install a distinct plugin (interlinked-opencode2) so v1 and v2 can both be enabled without clobbering .opencode/plugins/interlinked.ts or sharing a plugin id. v2 talks to the local harness socket; v1 keeps the managed hook-entry bridge. --- AGENTS.md | 2 +- skills/interlinked-setup/SKILL.md | 20 +- src/commands/disable.test.ts | 1 + src/commands/disable.ts | 1 + src/commands/enable.mutation-kill-w44.test.ts | 2 +- src/commands/enable.test.ts | 14 +- src/commands/enable.ts | 8 +- src/commands/env.mutation-kill-w56.test.ts | 2 +- src/commands/env.ts | 2 +- .../install-hooks.mutation-kill-w44.test.ts | 4 +- src/commands/install-hooks.test.ts | 2 +- src/commands/install-hooks.ts | 1 + src/commands/uninstall-hooks.ts | 1 + src/harness/adapters/README.md | 1 + src/harness/adapters/index.test.ts | 9 +- src/harness/adapters/index.ts | 5 +- src/harness/adapters/opencode.test.ts | 4 + src/harness/adapters/opencode.ts | 17 +- src/harness/adapters/opencode2.test.ts | 75 +++++ src/harness/adapters/opencode2.ts | 168 ++++++++++ src/harness/adapters/types.ts | 4 + src/harness/installer-manifest.ts | 1 + src/harness/interlinked-config.ts | 1 + src/harness/legacy-client.test.ts | 1 + src/harness/legacy-client.ts | 1 + src/harness/types/events.ts | 1 + src/harness/unified-event.ts | 1 + src/lib/collection/builder.ts | 1 + src/lib/hook-installers-opencode.test.ts | 73 +++++ src/lib/hook-installers-opencode.ts | 105 ++++++ src/lib/hook-installers.ts | 6 + .../hook-template-chunks/collection-writer.ts | 1 + src/lib/hook-types.ts | 2 + src/lib/hooks-template.ts | 1 + src/lib/hooks.ts | 6 + src/lib/opencode-plugin-source.ts | 299 ++++++++++++++++++ src/lib/opencode-runtime.ts | 10 + src/lib/opencode-tool-map.test.ts | 65 ++++ src/lib/opencode-tool-map.ts | 131 ++++++++ src/lib/settings.ts | 16 +- src/lib/skill-install-ownership.ts | 1 + src/lib/skill-install-templates.test.ts | 3 + src/lib/skill-install-templates.ts | 2 + src/lib/skill-installers.test.ts | 1 + src/lib/viz/agent-roster.ts | 2 +- src/registrars/setup.mutation-kill.test.ts | 6 +- src/registrars/setup.ts | 4 +- 47 files changed, 1048 insertions(+), 36 deletions(-) create mode 100644 src/harness/adapters/opencode2.test.ts create mode 100644 src/harness/adapters/opencode2.ts create mode 100644 src/lib/hook-installers-opencode.test.ts create mode 100644 src/lib/hook-installers-opencode.ts create mode 100644 src/lib/opencode-plugin-source.ts create mode 100644 src/lib/opencode-runtime.ts create mode 100644 src/lib/opencode-tool-map.test.ts create mode 100644 src/lib/opencode-tool-map.ts diff --git a/AGENTS.md b/AGENTS.md index 553ff77b..ec1f22fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ npm run dev -- status --short - Rule/check metadata changes require `npm run docs`; do not hand-edit stale generated counts. - Major changes in this repository MUST include a skill-impact review. This includes user-visible CLI commands/options/output, harness behavior or policy, configuration schemas/defaults, supported-client behavior, baseline semantics, and architecture or operator workflows. Update every affected `skills/*/SKILL.md` in the same change; code or design-doc updates alone are not complete when agent guidance changed. If no skill is affected, state why in the final handoff. - Keep the skill router and focused skills aligned: update `skills/interlinked/SKILL.md` when a capability's routing changes, and update the relevant focused skill with the current command/config contract and operational gotchas. Treat `.agents/skills/` as an installed/generated copy, not the source of truth. Validate every touched skill folder with the skill validator before handoff. -- Supported coding clients (`interlinked enable --clients `): `claude`, `copilot`, `gemini`, `codex`, `cursor`, `opencode`, `pi`. Native hook contracts live in `src/harness/adapters/`; legacy/per-client migration helpers live in `src/lib/hook-installers*.ts`; `src/lib/hooks.ts` is the orchestrator that wires them through `CLIENT_INSTALL_REGISTRY`. The generated `.mjs` script disambiguates Codex (Claude-shaped payloads) from Claude via the `INTERLINKED_CLIENT` env var set by the installed hook command. OpenCode and Pi use managed bridge files rather than the generated `.mjs` runtime. +- Supported coding clients (`interlinked enable --clients `): `claude`, `copilot`, `gemini`, `codex`, `cursor`, `opencode`, `opencode2`, `pi`. Native hook contracts live in `src/harness/adapters/`; legacy/per-client migration helpers live in `src/lib/hook-installers*.ts`; `src/lib/hooks.ts` is the orchestrator that wires them through `CLIENT_INSTALL_REGISTRY`. The generated `.mjs` script disambiguates Codex (Claude-shaped payloads) from Claude via the `INTERLINKED_CLIENT` env var set by the installed hook command. OpenCode v1 and Pi use managed bridge files rather than the generated `.mjs` runtime. OpenCode v1 installs `.opencode/plugins/interlinked.ts`; OpenCode v2 (`opencode2`) installs `.opencode/plugins/interlinked-opencode2.ts`. Both can be enabled in the same repo: distinct filenames and plugin ids, and each plugin no-ops in the other binary. - Codex CLI hook gating: `installCodexHooks` writes `.codex/hooks.json` AND ensures `[features] hooks = true` in `.codex/config.toml`. Without that flag Codex silently ignores the hooks.json file. Uninstall leaves config.toml untouched to avoid clobbering user-managed Codex configuration. ## Coding Style & Naming Conventions diff --git a/skills/interlinked-setup/SKILL.md b/skills/interlinked-setup/SKILL.md index 212c9ada..df045101 100644 --- a/skills/interlinked-setup/SKILL.md +++ b/skills/interlinked-setup/SKILL.md @@ -69,9 +69,9 @@ prompts on genuinely NEW packages. > block is a message the agent must guess at, and the likeliest guess is to work around the gate. Key `enable` flags: `--server ` · `--agent ` · `--clients ` -(`claude,copilot,gemini,codex,cursor,opencode,pi`) · `--sync-mode ` · +(`claude,copilot,gemini,codex,cursor,opencode,opencode2,pi`) · `--sync-mode ` · `--data-dir ` · `--structure ` · `--dry-run`. -`install-hooks` uses different vocabulary: `--runner ` +`install-hooks` uses different vocabulary: `--runner ` · `--scope ` · `--mode `. Explicit values are strict: an unknown client, sync mode, structure mode, install scope, or enforcement mode exits nonzero before config, hook, manifest, stand-down, binary-fallback, @@ -468,14 +468,14 @@ interlinked logout [--all] - **Gemini is a compatibility lane, not the Antigravity adapter.** Consumer Gemini CLI service ended in June 2026, while enterprise and paid API-key Gemini CLI use remain supported. The current `gemini` client installs Gemini CLI hooks/skills; do not treat it as Antigravity. -- **OpenCode and Pi installs are managed source bridges.** Project scope writes - `.opencode/plugins/interlinked.ts` or `.pi/extensions/interlinked.js` as an Interlinked-owned - whole file; user scope uses `~/.config/opencode/plugins/interlinked.ts` and - `~/.pi/agent/extensions/interlinked.js`. Install refuses to overwrite a foreign file at that - path, and uninstall preserves a bridge whose bytes changed after install. A loaded bridge writes - its provider row to `.interlinked/hook-runtime.json`, but only Codex currently has a dedicated - doctor trust-verification row. Restart OpenCode after install (its plugin trust is implicit). In - Pi, run `/reload` or restart and approve the project-extension trust prompt. +- **OpenCode and Pi installs are managed source bridges.** OpenCode v1 writes + `.opencode/plugins/interlinked.ts` (user: `~/.config/opencode/plugins/interlinked.ts`). + OpenCode v2 (`opencode2`) writes `.opencode/plugins/interlinked-opencode2.ts` (user: + `~/.config/opencode-v2/opencode/plugins/`). Distinct filenames and plugin ids, so both + can be enabled in one repo; each plugin no-ops in the other binary. Pi writes + `.pi/extensions/interlinked.js`. Install refuses to overwrite a foreign file at that + path, and uninstall preserves a bridge whose bytes changed after install. Restart OpenCode + after install. In Pi, run `/reload` or restart and approve the project-extension trust prompt. - **Their native parity is intentionally bounded.** OpenCode's stable tool-before hook cannot open confirmation, so `ask` denies; permission bus and `session.idle`/Stop are observation-only. Pi prompts through `ctx.ui.confirm` when interactive and denies headless; `user_bash` gates diff --git a/src/commands/disable.test.ts b/src/commands/disable.test.ts index b5294dd1..9358f5a3 100644 --- a/src/commands/disable.test.ts +++ b/src/commands/disable.test.ts @@ -103,6 +103,7 @@ const ALL_CLIENTS: ClientName[] = [ "codex", "cursor", "opencode", + "opencode2", "pi", ]; diff --git a/src/commands/disable.ts b/src/commands/disable.ts index d6f30d75..aaeb92f7 100644 --- a/src/commands/disable.ts +++ b/src/commands/disable.ts @@ -42,6 +42,7 @@ const ALL_CLIENTS: ClientName[] = [ "codex", "cursor", "opencode", + "opencode2", "pi", ]; diff --git a/src/commands/enable.mutation-kill-w44.test.ts b/src/commands/enable.mutation-kill-w44.test.ts index 05bdb7ca..a08fb3d3 100644 --- a/src/commands/enable.mutation-kill-w44.test.ts +++ b/src/commands/enable.mutation-kill-w44.test.ts @@ -386,7 +386,7 @@ describe("printSummary", () => { await enableCommand({}); expect(logged(logSpy)).toContain( - "--clients claude,copilot,gemini,codex,cursor,opencode,pi", + "--clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi", ); }); }); diff --git a/src/commands/enable.test.ts b/src/commands/enable.test.ts index 6c6d9753..1b6fb0dd 100644 --- a/src/commands/enable.test.ts +++ b/src/commands/enable.test.ts @@ -80,6 +80,7 @@ vi.mock("../lib/settings.js", () => ({ codex: "codex", cursor: "cursor", opencode: "opencode", + opencode2: "opencode2", pi: "pi", }, })); @@ -168,6 +169,7 @@ function eventCount(client: ClientName): number { codex: "codex", cursor: "cursor", opencode: "opencode", + opencode2: "opencode2", pi: "pi", } as const; const adapter = getAdapter(runner[client]); @@ -283,7 +285,7 @@ describe("enableCommand — dry run", () => { it("renders every requested client's event summary via --clients", async () => { vi.mocked(detectClients).mockReturnValue([]); - await enableCommand({ dryRun: true, clients: "copilot,gemini,codex,cursor,opencode,pi" }); + await enableCommand({ dryRun: true, clients: "copilot,gemini,codex,cursor,opencode,opencode2,pi" }); const out = logged(logSpy); expect(out).toContain(`copilot — ${eventCount("copilot")} events (.github/hooks/hooks.json) (forced)`); @@ -293,6 +295,9 @@ describe("enableCommand — dry run", () => { expect(out).toContain( `opencode — ${eventCount("opencode")} events (.opencode/plugins/interlinked.ts)`, ); + expect(out).toContain( + `opencode2 — ${eventCount("opencode2")} events (.opencode/plugins/interlinked-opencode2.ts)`, + ); expect(out).toContain( `pi — ${eventCount("pi")} events (.pi/extensions/interlinked.js)`, ); @@ -319,7 +324,7 @@ describe("enableCommand — explicit client validation", () => { expect(logged(errorSpy)).toContain("Unknown client: bogus"); expect(logged(errorSpy)).toContain( - "Supported clients: claude,copilot,gemini,codex,cursor,opencode,pi", + "Supported clients: claude,copilot,gemini,codex,cursor,opencode,opencode2,pi", ); expect(process.exitCode).toBe(1); expect(vi.mocked(isConfigured)).not.toHaveBeenCalled(); @@ -608,7 +613,7 @@ describe("enableCommand — hook managers + install results", () => { const out = logged(logSpy); expect(out).toContain("Warning: No hooks were installed."); expect(out).toContain("No client directories"); - expect(out).toContain("Use --clients claude,copilot,gemini,codex,cursor,opencode,pi"); + expect(out).toContain("Use --clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi"); }); it("warns WITHOUT the directory hint when clients were detected but none installed", async () => { @@ -812,7 +817,7 @@ describe("enableCommand — undetected client hint", () => { await enableCommand({}); const out = logged(logSpy); - expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, pi"); + expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, opencode2, pi"); expect(out).toContain("(add with --clients)"); }); @@ -830,6 +835,7 @@ describe("enableCommand — undetected client hint", () => { detected("codex", true), detected("cursor", true), detected("opencode", true), + detected("opencode2", true), detected("pi", true), ]); vi.mocked(installAllHooks).mockReturnValue([ diff --git a/src/commands/enable.ts b/src/commands/enable.ts index e73c40f7..442dece1 100644 --- a/src/commands/enable.ts +++ b/src/commands/enable.ts @@ -60,6 +60,7 @@ const ALL_CLIENTS: readonly ClientName[] = [ "codex", "cursor", "opencode", + "opencode2", "pi", ] as const; @@ -77,6 +78,7 @@ const CLIENT_DESTINATIONS: Record = { codex: ".codex/hooks.json + [features] hooks=true flag", cursor: ".cursor/hooks.json", opencode: ".opencode/plugins/interlinked.ts", + opencode2: ".opencode/plugins/interlinked-opencode2.ts", pi: ".pi/extensions/interlinked.js", }; @@ -342,7 +344,7 @@ function printInstallResults(results: InstallResultLike[], detected: ClientName[ ); console.log( c.dim( - " Use --clients claude,copilot,gemini,codex,cursor,opencode,pi to force installation.", + " Use --clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi to force installation.", ), ); } @@ -457,7 +459,7 @@ function printSummary( } } else { console.log( - `\n${c.yellow("Hooks are not active.")} No hook entries were installed. Re-run with ${c.cyan("--clients claude,copilot,gemini,codex,cursor,opencode,pi")} or check client settings paths.`, + `\n${c.yellow("Hooks are not active.")} No hook entries were installed. Re-run with ${c.cyan("--clients claude,copilot,gemini,codex,cursor,opencode,opencode2,pi")} or check client settings paths.`, ); } } @@ -470,7 +472,7 @@ export function buildPostEnableNotes(targetClients: readonly ClientName[]): stri if (targetClients.includes("codex")) { notes.push("Restart Codex or open a new Codex session to load updated hooks."); } - if (targetClients.includes("opencode")) { + if (targetClients.includes("opencode") || targetClients.includes("opencode2")) { notes.push("Restart OpenCode or open a new OpenCode session to load the Interlinked plugin."); } if (targetClients.includes("pi")) { diff --git a/src/commands/env.mutation-kill-w56.test.ts b/src/commands/env.mutation-kill-w56.test.ts index 5a9bb682..713041a0 100644 --- a/src/commands/env.mutation-kill-w56.test.ts +++ b/src/commands/env.mutation-kill-w56.test.ts @@ -86,7 +86,7 @@ describe("envCommand — json mode descriptions/examples", () => { "Comma-separated list of clients for non-interactive bootstrap", ); expect(byName.INTERLINKED_CLIENTS.example).toBe( - "claude,copilot,gemini,codex,cursor,opencode,pi", + "claude,copilot,gemini,codex,cursor,opencode,opencode2,pi", ); }); }); diff --git a/src/commands/env.ts b/src/commands/env.ts index c1cce46b..4454411c 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -85,7 +85,7 @@ function getEnvVars(): EnvVarDoc[] { { name: "INTERLINKED_CLIENTS", description: "Comma-separated list of clients for non-interactive bootstrap", - example: "claude,copilot,gemini,codex,cursor,opencode,pi", + example: "claude,copilot,gemini,codex,cursor,opencode,opencode2,pi", currentValue: process.env.INTERLINKED_CLIENTS, }, ]; diff --git a/src/commands/install-hooks.mutation-kill-w44.test.ts b/src/commands/install-hooks.mutation-kill-w44.test.ts index a18b5db5..a6d6eeb0 100644 --- a/src/commands/install-hooks.mutation-kill-w44.test.ts +++ b/src/commands/install-hooks.mutation-kill-w44.test.ts @@ -213,14 +213,14 @@ describe("parseRunners (mutants f0c1edd2a57e52e0, 91195e68e2570134, 8cd077e5ef93 describe("VALID_RUNNERS / VALID_SCOPES literals (mutant symbol 41d0eb8f71fbd934)", () => { it("recognizes cursor, gemini-cli, codex, OpenCode, and Pi as valid runners", async () => { await installHooksCommand({ - runner: "cursor,gemini-cli,codex,opencode,pi", + runner: "cursor,gemini-cli,codex,opencode,opencode2,pi", mode: "balanced", json: true, dryRun: true, }); expect(installHooksMock).toHaveBeenCalledWith( expect.objectContaining({ - runners: ["cursor", "gemini-cli", "codex", "opencode", "pi"], + runners: ["cursor", "gemini-cli", "codex", "opencode", "opencode2", "pi"], }), ); expect(stderrText()).not.toContain("unknown runner"); diff --git a/src/commands/install-hooks.test.ts b/src/commands/install-hooks.test.ts index 3aec6935..14e094fe 100644 --- a/src/commands/install-hooks.test.ts +++ b/src/commands/install-hooks.test.ts @@ -164,7 +164,7 @@ describe("install-hooks command", () => { const manifest = JSON.parse( readFileSync(join(tmp, ".interlinked", "installer-manifest.json"), "utf-8"), ) as { entries: Array<{ runner: string }> }; - expect(manifest.entries.length).toBe(7); + expect(manifest.entries.length).toBe(8); }); it("respects --dry-run", async () => { diff --git a/src/commands/install-hooks.ts b/src/commands/install-hooks.ts index 99020b92..fe0f0e3a 100644 --- a/src/commands/install-hooks.ts +++ b/src/commands/install-hooks.ts @@ -41,6 +41,7 @@ const VALID_RUNNERS = new Set([ "gemini-cli", "codex", "opencode", + "opencode2", "pi", ]); const VALID_SCOPES = new Set(["user", "project", "local"]); diff --git a/src/commands/uninstall-hooks.ts b/src/commands/uninstall-hooks.ts index 18e252ea..2be0869a 100644 --- a/src/commands/uninstall-hooks.ts +++ b/src/commands/uninstall-hooks.ts @@ -20,6 +20,7 @@ const VALID_RUNNERS = new Set([ "gemini-cli", "codex", "opencode", + "opencode2", "pi", ]); diff --git a/src/harness/adapters/README.md b/src/harness/adapters/README.md index 1bfa38e1..7fd5bc83 100644 --- a/src/harness/adapters/README.md +++ b/src/harness/adapters/README.md @@ -20,6 +20,7 @@ into the runner's stdout/stderr/exit-code contract. | Codex CLI | `codex` | Supported | Complete 12-event native surface | native hook JSON with permission/continuation envelopes | `PreToolUse` ask → deny; `PermissionRequest` uses native prompt | ✅ `additionalContext` | | Gemini CLI | `gemini-cli` | Experimental | 9 events | stdout JSON (provisional) | 🚧 provisional | 🚧 provisional | | OpenCode | `opencode` | Experimental managed plugin | 11 installed callbacks: generic tool/prompt/compaction plus session/permission bus observations | throw from `tool.execute.before` to deny | ❌ → deny | ✅ tool result/context mutation; bus events observe only | +| OpenCode v2 (`opencode2`) | `opencode2` | Experimental plugin | 5 plugin hooks: `tool.execute.before/after`, `session.created/deleted/idle` | socket JSON `{decision}`; in-process throw on block | ❌ → deny | ✅ append to tool output | | Pi | `pi` | Experimental managed extension | 13 callbacks, including `tool_call`, `tool_result`, `input`, `user_bash`, lifecycle, and compaction | Pi extension return objects | ✅ `ctx.ui.confirm`; headless → deny | ✅ `tool_result` content + UI notification | **Native ask** = runner has a user-confirm primitive (Claude `permissionDecision: "ask"`, Cursor `permission: "ask"` on `beforeShellExecution` / `beforeMCPExecution`, Pi `ctx.ui.confirm` when `ctx.hasUI`). When absent — including headless Pi and OpenCode's stable `tool.execute.before` — the harness collapses canonical `ask` to a hard deny so the user still sees the reason and can refine. diff --git a/src/harness/adapters/index.test.ts b/src/harness/adapters/index.test.ts index 8e4efb26..48c410f5 100644 --- a/src/harness/adapters/index.test.ts +++ b/src/harness/adapters/index.test.ts @@ -6,8 +6,8 @@ import { buildAllAdapters, detectAdapter, getAdapter } from "./index.js"; describe("buildAllAdapters", () => { const adapters = buildAllAdapters(); - it("returns all seven runner adapters", () => { - expect(adapters.length).toBe(7); + it("returns all eight runner adapters", () => { + expect(adapters.length).toBe(8); const ids = adapters.map((a) => a.id).sort(); expect(ids).toEqual([ "claude-code", @@ -16,6 +16,7 @@ describe("buildAllAdapters", () => { "cursor", "gemini-cli", "opencode", + "opencode2", "pi", ]); }); @@ -58,6 +59,9 @@ describe("detectAdapter", () => { it("detects OpenCode via OPENCODE", () => { expect(detectAdapter({ OPENCODE: "1" })?.id).toBe("opencode"); }); + it("detects OpenCode v2 via OPENCODE2 before OPENCODE*", () => { + expect(detectAdapter({ OPENCODE2: "1" })?.id).toBe("opencode2"); + }); it("detects Pi via PI_CODING_AGENT", () => { expect(detectAdapter({ PI_CODING_AGENT: "1" })?.id).toBe("pi"); }); @@ -69,6 +73,7 @@ describe("getAdapter", () => { expect(getAdapter("copilot-cli")?.id).toBe("copilot-cli"); expect(getAdapter("cursor")?.id).toBe("cursor"); expect(getAdapter("opencode")?.id).toBe("opencode"); + expect(getAdapter("opencode2")?.id).toBe("opencode2"); expect(getAdapter("pi")?.id).toBe("pi"); }); it("returns null for unknown ids", () => { diff --git a/src/harness/adapters/index.ts b/src/harness/adapters/index.ts index 9b0b359f..d986a904 100644 --- a/src/harness/adapters/index.ts +++ b/src/harness/adapters/index.ts @@ -10,6 +10,7 @@ import { createCopilotCliAdapter } from "./copilot-cli.js"; import { createCursorAdapter } from "./cursor.js"; import { createGeminiCliAdapter } from "./gemini-cli.js"; import { createOpenCodeAdapter } from "./opencode.js"; +import { createOpencode2Adapter } from "./opencode2.js"; import { createPiAdapter } from "./pi.js"; import type { RunnerAdapter } from "./types.js"; @@ -25,6 +26,7 @@ export function buildAllAdapters(opts: AdapterRegistryOptions = {}): RunnerAdapt createCursorAdapter({ overrides: opts.overrides }), createGeminiCliAdapter({ overrides: opts.overrides }), createCodexAdapter({ overrides: opts.overrides }), + createOpencode2Adapter({ overrides: opts.overrides }), createOpenCodeAdapter({ overrides: opts.overrides }), createPiAdapter({ overrides: opts.overrides }), ]; @@ -32,7 +34,7 @@ export function buildAllAdapters(opts: AdapterRegistryOptions = {}): RunnerAdapt /** Detect which adapter the current process environment best matches. The * first adapter whose `detectFromEnv` returns true wins. Stable ordering: - * claude-code → copilot-cli → cursor → gemini-cli → codex → opencode → pi. */ + * claude-code → copilot-cli → cursor → gemini-cli → codex → opencode2 → opencode → pi. */ export function detectAdapter( env: NodeJS.ProcessEnv, adapters: RunnerAdapter[] = buildAllAdapters(), @@ -57,6 +59,7 @@ export { createCopilotCliAdapter } from "./copilot-cli.js"; export { createCursorAdapter } from "./cursor.js"; export { createGeminiCliAdapter } from "./gemini-cli.js"; export { createOpenCodeAdapter, renderOpenCodeBridgeSource } from "./opencode.js"; +export { createOpencode2Adapter } from "./opencode2.js"; export { createPiAdapter, renderPiBridgeSource } from "./pi.js"; export { buildStandardAction, diff --git a/src/harness/adapters/opencode.test.ts b/src/harness/adapters/opencode.test.ts index 41aaabc0..fe92eff7 100644 --- a/src/harness/adapters/opencode.test.ts +++ b/src/harness/adapters/opencode.test.ts @@ -16,6 +16,8 @@ describe("OpenCode adapter", () => { it("detects explicit OpenCode process markers only", () => { expect(adapter.detectFromEnv({ OPENCODE: "1" })).toBe(true); expect(adapter.detectFromEnv({ INTERLINKED_CLIENT: "opencode" })).toBe(true); + expect(adapter.detectFromEnv({ OPENCODE2: "1" })).toBe(false); + expect(adapter.detectFromEnv({ OPENCODE: "1", OPENCODE2: "1" })).toBe(false); expect(adapter.detectFromEnv({})).toBe(false); }); @@ -108,6 +110,8 @@ describe("OpenCode managed plugin", () => { expect(fragment.fileContent).toContain('hook_event_name: interlinkedLegacyHookEvent(eventName)'); expect(fragment.fileContent).toContain('.join("\\n")'); expect(fragment.fileContent).toContain('"\\n\\n[interlinked]\\n"'); + expect(fragment.fileContent).toContain('export default { id: "interlinked", setup: async () => {} }'); + expect(fragment.fileContent).toContain("if (interlinkedIsOpenCodeV2()) return {};"); }); it("hard-denies ask decisions because OpenCode cannot initiate native confirmation", () => { diff --git a/src/harness/adapters/opencode.ts b/src/harness/adapters/opencode.ts index a2fddcb0..b17bffc6 100644 --- a/src/harness/adapters/opencode.ts +++ b/src/harness/adapters/opencode.ts @@ -12,6 +12,7 @@ import { } from "./provider-bridge-source.js"; import { installedEventNames, OPENCODE_CAPABILITIES } from "./provider-capabilities.js"; import type { RunnerAdapter, SettingsFragment } from "./types.js"; +import { isOpenCodeV2Env } from "../../lib/opencode-runtime.js"; const NATIVE_EVENTS = installedEventNames(OPENCODE_CAPABILITIES); const PROJECT_PLUGIN_PATH = ".opencode/plugins/interlinked.ts"; @@ -136,6 +137,7 @@ export function createOpenCodeAdapter(opts: OpenCodeAdapterOptions = {}): Runner nativeEventNames: NATIVE_EVENTS, detectFromEnv(env) { + if (isOpenCodeV2Env(env)) return false; return Boolean( env.OPENCODE || env.OPENCODE_CLI || @@ -194,10 +196,21 @@ export function renderOpenCodeBridgeSource(binaryPath: string): string { return [ renderProviderBridgePrelude("opencode", binaryPath), OPENCODE_PLUGIN_HELPERS, - "export const InterlinkedPlugin = async ({ directory, worktree }) => ({", + "function interlinkedIsOpenCodeV2() {", + ' const env = typeof process === "undefined" ? {} : process.env;', + ' const argv = typeof process === "undefined" ? [] : process.argv;', + ' return Boolean(env.OPENCODE2) || env.INTERLINKED_CLIENT === "opencode2" || String(env.XDG_CONFIG_HOME || "").includes("opencode-v2") || argv.some((part) => String(part).includes("opencode2"));', + "}", + "export const InterlinkedPlugin = async ({ directory, worktree }) => {", + " if (interlinkedIsOpenCodeV2()) return {};", + " return {", OPENCODE_PLUGIN_TOOL_HOOKS, OPENCODE_PLUGIN_OTHER_HOOKS, - "});", + " };", + "};", + // v2's loader requires default.{id,setup}. No-op so this file can sit + // next to interlinked-opencode2.ts without crashing opencode2 or double-gating. + 'export default { id: "interlinked", setup: async () => {} };', "", ].join("\n"); } diff --git a/src/harness/adapters/opencode2.test.ts b/src/harness/adapters/opencode2.test.ts new file mode 100644 index 00000000..058f51f3 --- /dev/null +++ b/src/harness/adapters/opencode2.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { createOpencode2Adapter } from "./opencode2.js"; + +const adapter = createOpencode2Adapter(); + +describe("OpenCode adapter identity", () => { + it("has the expected id and native events", () => { + expect(adapter.id).toBe("opencode2"); + expect(adapter.nativeEventNames).toContain("tool.execute.before"); + expect(adapter.nativeEventNames).toContain("session.idle"); + }); + + it("installs a v2-only plugin path, not the v1 interlinked.ts bridge", () => { + const project = adapter.renderSettingsFragment("/bin/hook", "project"); + expect(project.path).toBe(".opencode/plugins/interlinked-opencode2.ts"); + expect(project.fileContent).toContain("interlinked-opencode2"); + expect(adapter.renderSettingsFragment("/bin/hook", "user").path).toBe( + "~/.config/opencode-v2/opencode/plugins/interlinked-opencode2.ts", + ); + }); +}); + +describe("OpenCode detectFromEnv", () => { + it("P1: detects OPENCODE2", () => { + expect(adapter.detectFromEnv({ OPENCODE2: "1" })).toBe(true); + }); + it("P2: detects the v2 XDG config home", () => { + expect(adapter.detectFromEnv({ XDG_CONFIG_HOME: "/home/agent/.config/opencode-v2" })).toBe(true); + }); + it("N1: ignores a plain env", () => { + expect(adapter.detectFromEnv({})).toBe(false); + }); + it("N2: ignores v1 OPENCODE without v2 markers", () => { + expect(adapter.detectFromEnv({ OPENCODE: "1" })).toBe(false); + }); +}); + +describe("OpenCode parseHookInput", () => { + it("P1: maps tool.execute.before edit to a tool_call", () => { + const event = adapter.parseHookInput( + { sessionID: "s1", cwd: "/r", tool: "edit", args: { filePath: "/r/a.ts", oldString: "a", newString: "b" } }, + "tool.execute.before", + ); + expect(event.phase).toBe("pre-tool"); + expect(event.runner).toBe("opencode2"); + if (event.action.kind !== "tool_call") throw new Error("expected tool_call"); + expect(event.action.tool_name).toBe("edit"); + expect(event.action.tool_input).toMatchObject({ file_path: "/r/a.ts" }); + }); + + it("P2: session.created is a lifecycle event", () => { + const event = adapter.parseHookInput({ sessionID: "s2" }, "session.created"); + expect(event.phase).toBe("session-start"); + expect(event.action.kind).toBe("other"); + }); +}); + +describe("OpenCode encodeDecision", () => { + it("P1: block exits 2 with a reason", () => { + const out = adapter.encodeDecision( + { decision: "block", reason: "nope", warnings: [] }, + adapter.parseHookInput({}, "tool.execute.before"), + ); + expect(out.exit_code).toBe(2); + expect(out.stdout).toContain("nope"); + }); + + it("N1: allow exits 0", () => { + const out = adapter.encodeDecision( + { decision: "allow", warnings: [] }, + adapter.parseHookInput({}, "tool.execute.before"), + ); + expect(out.exit_code).toBe(0); + }); +}); diff --git a/src/harness/adapters/opencode2.ts b/src/harness/adapters/opencode2.ts new file mode 100644 index 00000000..c7098d37 --- /dev/null +++ b/src/harness/adapters/opencode2.ts @@ -0,0 +1,168 @@ +// =========================================== +// OpenCode v2 (opencode2) adapter (as of 2026-08-31) +// =========================================== +// Opencode v2 (`opencode2` binary) does not spawn stdin hook processes. +// Integration is an in-process plugin (`tool.execute.before` / `after`, +// session events) that posts Claude-shaped HarnessEvent JSON to +// `.interlinked/harness.sock`. +// +// This adapter still exists so `install-hooks --runner opencode2` and the +// unified event path stay consistent with the other runners. The plugin +// itself talks to the legacy socket; encodeDecision is used if hook-entry +// is ever invoked with --runner opencode2. + +import { mapOpencode2Tool } from "../../lib/opencode-tool-map.js"; +import { isOpenCodeV2Env } from "../../lib/opencode-runtime.js"; +import { installOpencode2Hooks, uninstallOpencode2Hooks } from "../../lib/hook-installers-opencode.js"; +import { buildOpencodePluginSource, OPENCODE_PLUGIN_FILENAME } from "../../lib/opencode-plugin-source.js"; +import type { JsonObject } from "../../lib/json-types.js"; +import { + type ClassifierOverrides, + classifyFromToolName, +} from "../tool-class-classifier.js"; +import type { UnifiedPhase } from "../unified-event.js"; +import { makeEventId } from "../unified-event.js"; +import type { AdapterOutput, PostInstallOptions, RunnerAdapter, SettingsFragment } from "./types.js"; + +const NATIVE_EVENTS = [ + "tool.execute.before", + "tool.execute.after", + "session.created", + "session.deleted", + "session.idle", + "PreToolUse", + "PostToolUse", + "SessionStart", + "SessionEnd", + "Stop", +] as const; + +const PHASE_MAP: Record = { + "tool.execute.before": "pre-tool", + "tool.execute.after": "post-tool", + "session.created": "session-start", + "session.deleted": "session-end", + "session.idle": "stop", + PreToolUse: "pre-tool", + PostToolUse: "post-tool", + SessionStart: "session-start", + SessionEnd: "session-end", + Stop: "stop", +}; + +export interface OpencodeAdapterOptions { + overrides?: ClassifierOverrides | undefined; +} + +export function createOpencode2Adapter(opts: OpencodeAdapterOptions = {}): RunnerAdapter { + return { + id: "opencode2", + label: "OpenCode v2", + nativeEventNames: NATIVE_EVENTS, + + detectFromEnv(env) { + return isOpenCodeV2Env(env); + }, + + parseHookInput(nativeJson, nativeEventName) { + const raw = isObject(nativeJson) ? nativeJson : {}; + const phase = PHASE_MAP[nativeEventName] ?? "other"; + const session_id = + readString(raw.session_id) ?? readString(raw.sessionID) ?? readString(raw.sessionId) ?? "unknown"; + const cwd = readString(raw.cwd) ?? process.cwd(); + const ts = new Date().toISOString(); + const tool = readString(raw.tool) ?? readString(raw.tool_name) ?? ""; + const args = raw.args ?? raw.tool_input ?? {}; + const mapped = tool ? mapOpencode2Tool(tool, args) : null; + const toolName = mapped?.tool_name ?? tool; + const toolInput = mapped?.tool_input ?? args; + const action = + phase === "pre-tool" || phase === "post-tool" + ? { + kind: "tool_call" as const, + tool_name: toolName.toLowerCase(), + tool_class: classifyFromToolName( + toolName, + toolInput, + opts.overrides ? { overrides: opts.overrides } : {}, + ), + tool_input: toolInput, + tool_input_redacted: toolInput, + } + : { kind: "other" as const, subkind: nativeEventName, data: raw }; + + return { + schema_version: "1", + event_id: makeEventId(), + session_id, + ts, + runner: "opencode2", + runner_native_event: nativeEventName, + phase, + action, + context: { cwd }, + raw, + }; + }, + + classifyToolClass(toolName, toolInput) { + return classifyFromToolName( + toolName, + toolInput, + opts.overrides ? { overrides: opts.overrides } : {}, + ); + }, + + renderSettingsFragment(_binaryPath, scope): SettingsFragment { + const path = + scope === "user" + ? `~/.config/opencode-v2/opencode/plugins/${OPENCODE_PLUGIN_FILENAME}` + : `.opencode/plugins/${OPENCODE_PLUGIN_FILENAME}`; + return { + path, + fragment: {}, + mergeStrategy: "deep-merge", + fileContent: buildOpencodePluginSource(), + }; + }, + + encodeDecision(decision): AdapterOutput { + const stderr = (decision.warnings ?? []).join("\n"); + if (decision.decision === "block" || decision.decision === "ask") { + return { + stdout: JSON.stringify({ + decision: "block", + reason: decision.reason ?? "Blocked by Interlinked", + }), + stderr: stderr || undefined, + exit_code: 2, + }; + } + const payload: JsonObject = { decision: "allow" }; + if (decision.additional_context) payload.additional_context = decision.additional_context; + return { + stdout: JSON.stringify(payload), + stderr: stderr || undefined, + exit_code: 0, + }; + }, + + postInstall(options: PostInstallOptions) { + if (options.dryRun) return; + installOpencode2Hooks(options.cwd, ""); + }, + + postUninstall(options: PostInstallOptions) { + if (options.dryRun) return; + uninstallOpencode2Hooks(options.cwd); + }, + }; +} + +function isObject(v: unknown): v is JsonObject { + return v != null && typeof v === "object" && !Array.isArray(v); +} + +function readString(v: unknown): string | null { + return typeof v === "string" ? v : null; +} diff --git a/src/harness/adapters/types.ts b/src/harness/adapters/types.ts index 5e1f1bd2..dae71228 100644 --- a/src/harness/adapters/types.ts +++ b/src/harness/adapters/types.ts @@ -160,6 +160,10 @@ export interface RunnerAdapter { * writing the JSON fragment, with the resolved scope and the dryRun * flag so adapters can no-op or trace under `--dry-run`. */ postInstall?(opts: PostInstallOptions): void; + + /** Optional cleanup after the JSON fragment has been unmerged. OpenCode v2 + * writes a JS plugin file the JSON merger cannot own. */ + postUninstall?(opts: PostInstallOptions): void; } export interface PostInstallOptions { diff --git a/src/harness/installer-manifest.ts b/src/harness/installer-manifest.ts index 34886880..c519c074 100644 --- a/src/harness/installer-manifest.ts +++ b/src/harness/installer-manifest.ts @@ -31,6 +31,7 @@ const VALID_MANIFEST_RUNNERS = new Set([ "gemini-cli", "codex", "opencode", + "opencode2", "pi", ]); diff --git a/src/harness/interlinked-config.ts b/src/harness/interlinked-config.ts index 1728765b..978e5606 100644 --- a/src/harness/interlinked-config.ts +++ b/src/harness/interlinked-config.ts @@ -161,6 +161,7 @@ const VALID_RUNNERS: readonly RunnerId[] = [ "gemini-cli", "cursor", "opencode", + "opencode2", "pi", "unknown", ]; diff --git a/src/harness/legacy-client.test.ts b/src/harness/legacy-client.test.ts index 882535cc..31e105c3 100644 --- a/src/harness/legacy-client.test.ts +++ b/src/harness/legacy-client.test.ts @@ -924,6 +924,7 @@ describe("mapAgentSource", () => { ["gemini-cli", "gemini"], ["cursor", "cursor"], ["opencode", "opencode"], + ["opencode2", "opencode2"], ["pi", "pi"], ] as const)("maps runner %s to agent_source %s", (runner, expected) => { expect(toLegacyHarnessEvent(makeEvent({ runner })).agent_source).toBe(expected); diff --git a/src/harness/legacy-client.ts b/src/harness/legacy-client.ts index c50f5e25..f72773f4 100644 --- a/src/harness/legacy-client.ts +++ b/src/harness/legacy-client.ts @@ -293,6 +293,7 @@ const AGENT_SOURCE_BY_RUNNER: Partial { + let dir: string; + const dirs: string[] = []; + + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + function tmp(): string { + dir = mkdtempSync(join(tmpdir(), "opencode-install-")); + dirs.push(dir); + return dir; + } + + it("P1: writes a marked plugin under .opencode/plugins", () => { + const cwd = tmp(); + installOpencode2Hooks(cwd, "/unused/hook.mjs"); + const plugin = join(cwd, ".opencode", "plugins", "interlinked-opencode2.ts"); + const body = readFileSync(plugin, "utf-8"); + expect(body.startsWith("// interlinked-provider-bridge:v1\n")).toBe(true); + expect(body).toContain(OPENCODE_PLUGIN_MARKER); + expect(body).toContain("execute.before"); + expect(body).toContain("export default"); + expect(body).toContain('id: PLUGIN_ID'); + expect(body).toContain("export const InterlinkedPlugin"); + expect(body).toContain("setup:"); + expect(body).toContain("execute.before"); + expect(isOpencode2PluginInstalled(cwd)).toBe(true); + }); + + it("P2: uninstall removes our plugin", () => { + const cwd = tmp(); + installOpencode2Hooks(cwd, ""); + expect(uninstallOpencode2Hooks(cwd)).toBe(true); + expect(isOpencode2PluginInstalled(cwd)).toBe(false); + }); + + it("N1: uninstall is a no-op when the plugin is absent", () => { + expect(uninstallOpencode2Hooks(tmp())).toBe(false); + }); + + it("P3: install removes a leftover Interlinked .js plugin", () => { + const cwd = tmp(); + const dir = join(cwd, ".opencode", "plugins"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "interlinked.js"), `// ${OPENCODE_PLUGIN_MARKER}\n`); + installOpencode2Hooks(cwd, ""); + expect(existsSync(join(dir, "interlinked-opencode2.ts"))).toBe(true); + expect(existsSync(join(dir, "interlinked.js"))).toBe(false); + }); + + it("P4: does not overwrite a v1 managed interlinked.ts", () => { + const cwd = tmp(); + const dir = join(cwd, ".opencode", "plugins"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "interlinked.ts"), "// interlinked-provider-bridge:v1\nexport const InterlinkedPlugin = async () => ({});\n"); + installOpencode2Hooks(cwd, ""); + expect(readFileSync(join(dir, "interlinked.ts"), "utf-8")).toContain("interlinked-provider-bridge:v1"); + expect(existsSync(join(dir, "interlinked-opencode2.ts"))).toBe(true); + }); +}); diff --git a/src/lib/hook-installers-opencode.ts b/src/lib/hook-installers-opencode.ts new file mode 100644 index 00000000..d7476475 --- /dev/null +++ b/src/lib/hook-installers-opencode.ts @@ -0,0 +1,105 @@ +// =========================================== +// OpenCode v2 (opencode2) — Install/Uninstall +// =========================================== +// Opencode v2 has no stdin hooks.json. It auto-loads +// `.opencode/plugins/*.{ts,js}` (project) plus the v2 XDG plugin dir. +// The v1 managed bridge owns `.opencode/plugins/interlinked.ts`; this +// installer writes `interlinked-opencode2.ts` so both can be enabled. + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { + buildOpencodePluginSource, + OPENCODE_PLUGIN_FILENAME, + OPENCODE_PLUGIN_MARKER, +} from "./opencode-plugin-source.js"; + +/** Native plugin hooks we subscribe to (documented for enable summaries). */ +export const OPENCODE2_HOOK_EVENTS = [ + "tool.execute.before", + "tool.execute.after", + "session.created", + "session.deleted", + "session.idle", +] as const; + +export function opencodePluginRelPath(): string { + return join(".opencode", "plugins", OPENCODE_PLUGIN_FILENAME); +} + +export function getOpencodePluginPath(cwd: string, scope: "user" | "project" | "local" = "project"): string { + if (scope === "user") { + const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); + // opencode2's `plugin list` loads ~/.config/opencode/plugins/*.js + return join(home, ".config", "opencode", "plugins", OPENCODE_PLUGIN_FILENAME.replace(/\.ts$/, ".js")); + } + return join(cwd, ".opencode", "plugins", OPENCODE_PLUGIN_FILENAME); +} + +function writePlugin(path: string): void { + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, buildOpencodePluginSource()); +} + +function pluginPaths(cwd: string): string[] { + const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); + const project = getOpencodePluginPath(cwd, "project"); + const userV2 = getOpencodePluginPath(cwd, "user"); + const projectDir = dirname(project); + const v1UserDir = join(home, ".config", "opencode", "plugins"); + return [ + project, + userV2, + join(projectDir, "interlinked.js"), + join(projectDir, "interlinked.ts"), + join(v1UserDir, "interlinked.js"), + join(v1UserDir, OPENCODE_PLUGIN_FILENAME), + join(v1UserDir, "interlinked.ts"), + ]; +} + +function pluginIsOurs(path: string): boolean { + if (!existsSync(path)) return false; + try { + return readFileSync(path, "utf-8").includes(OPENCODE_PLUGIN_MARKER); + } catch { + return false; + } +} + +/** + * Public API — consumed by `src/lib/hooks.ts` (registered in CLIENT_INSTALL_REGISTRY). + * Install the Interlinked OpenCode v2 plugin. + */ +export function installOpencode2Hooks(cwd: string, _hookScriptPath: string): void { + const project = getOpencodePluginPath(cwd, "project"); + const userJs = getOpencodePluginPath(cwd, "user"); + writePlugin(project); + writePlugin(userJs); + for (const leftover of pluginPaths(cwd)) { + if (leftover === project || leftover === userJs) continue; + if (!pluginIsOurs(leftover)) continue; + rmSync(leftover, { force: true }); + } +} + +/** + * Public API — consumed by `src/lib/hooks.ts`. + * Remove the Interlinked OpenCode v2 plugin if we own it. + */ +export function uninstallOpencode2Hooks(cwd: string): boolean { + let changed = false; + for (const path of pluginPaths(cwd)) { + if (!pluginIsOurs(path)) continue; + rmSync(path, { force: true }); + changed = true; + } + return changed; +} + +/** True when the on-disk plugin is the Interlinked one. Used by doctor. */ +export function isOpencode2PluginInstalled(cwd: string): boolean { + return pluginPaths(cwd).some((path) => pluginIsOurs(path)); +} diff --git a/src/lib/hook-installers.ts b/src/lib/hook-installers.ts index 16a4ecbd..2894a125 100644 --- a/src/lib/hook-installers.ts +++ b/src/lib/hook-installers.ts @@ -48,6 +48,12 @@ export { installGeminiHooks, uninstallGeminiHooks, } from "./hook-installers-gemini.js"; +// OpenCode v2 (`opencode2` binary) — JS plugin, distinct from the v1 TS bridge. +export { + OPENCODE2_HOOK_EVENTS, + installOpencode2Hooks, + uninstallOpencode2Hooks, +} from "./hook-installers-opencode.js"; // Shared helpers (also public API for `hooks.ts` and the Claude installer). export { findParentWithHooks } from "./hook-installers-shared.js"; // Cross-client statusline installer. diff --git a/src/lib/hook-template-chunks/collection-writer.ts b/src/lib/hook-template-chunks/collection-writer.ts index 1e5eeaa6..800cdfde 100644 --- a/src/lib/hook-template-chunks/collection-writer.ts +++ b/src/lib/hook-template-chunks/collection-writer.ts @@ -44,6 +44,7 @@ const COLLECTION_DIRECT_PROVIDER_RUNNERS = new Set([ "gemini-cli", "cursor", "opencode", + "opencode2", "pi", ]); diff --git a/src/lib/hook-types.ts b/src/lib/hook-types.ts index f0c278c1..af14a787 100644 --- a/src/lib/hook-types.ts +++ b/src/lib/hook-types.ts @@ -84,6 +84,8 @@ export const CLIENT_GEMINI = "gemini" as const; export const CLIENT_CODEX = "codex" as const; /** Public API — consumed by `src/lib/hook-installers.ts`. */ export const CLIENT_CURSOR = "cursor" as const; +/** Public API — consumed by `src/lib/hook-installers.ts`. OpenCode v2 (`opencode2` binary). */ +export const CLIENT_OPENCODE2 = "opencode2" as const; /** * Public API — consumed by `src/lib/hook-installers.ts`. diff --git a/src/lib/hooks-template.ts b/src/lib/hooks-template.ts index 62f96ee5..e9fca822 100644 --- a/src/lib/hooks-template.ts +++ b/src/lib/hooks-template.ts @@ -1007,6 +1007,7 @@ async function main(rawInput) { // normalizer keeps the standalone .mjs fallback at guard parity; the // handler identity keeps attribution provider-correct. { name: "opencode", detect: (_input, src) => src === "stdin" && RUNNER_ENV === "opencode", normalize: normalizeClaudeEvent }, + { name: "opencode2", detect: (_input, src) => src === "stdin" && RUNNER_ENV === "opencode2", normalize: normalizeClaudeEvent }, { name: "pi", detect: (_input, src) => src === "stdin" && RUNNER_ENV === "pi", normalize: normalizeClaudeEvent }, { name: "claude", detect: (input, src) => src === "stdin", normalize: normalizeClaudeEvent }, ]; diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index c1250f6c..33e4151c 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -52,6 +52,8 @@ import { uninstallCopilotHooks, uninstallCursorHooks, uninstallGeminiHooks, + OPENCODE2_HOOK_EVENTS, + uninstallOpencode2Hooks, } from "./hook-installers.js"; import { fileURLToPath } from "node:url"; import { detectHookManagers, type HookManagerInfo } from "./hook-manager-detection.js"; @@ -338,6 +340,10 @@ const CLIENT_INSTALL_REGISTRY: Record = { events: OPENCODE_HOOK_EVENTS, uninstall: (cwd) => uninstallManagedClientHooks(cwd, "opencode"), }, + opencode2: { + events: OPENCODE2_HOOK_EVENTS, + uninstall: uninstallOpencode2Hooks, + }, pi: { events: PI_HOOK_EVENTS, uninstall: (cwd) => uninstallManagedClientHooks(cwd, "pi"), diff --git a/src/lib/opencode-plugin-source.ts b/src/lib/opencode-plugin-source.ts new file mode 100644 index 00000000..aaf347b2 --- /dev/null +++ b/src/lib/opencode-plugin-source.ts @@ -0,0 +1,299 @@ +// =========================================== +// Self-contained OpenCode plugin source +// =========================================== +// Written to `.opencode/plugins/interlinked-opencode2.ts` so it can sit +// next to the v1 managed bridge (`.opencode/plugins/interlinked.ts`) without +// colliding on filename or plugin id. OpenCode auto-loads +// `{plugin,plugins}/*.{ts,js}`. The file has ZERO imports from this package. + +/** Marker grepped by doctor / uninstall. Distinct from the v1 provider-bridge marker. */ +export const OPENCODE_PLUGIN_MARKER = "interlinked-opencode-plugin"; + +export const OPENCODE_PLUGIN_FILENAME = "interlinked-opencode2.ts"; +export const OPENCODE_PLUGIN_ID = "interlinked-opencode2"; + +/** Public API — the exact bytes dropped onto disk. */ +export function buildOpencodePluginSource(): string { + return `// interlinked-provider-bridge:v1 +// ${OPENCODE_PLUGIN_MARKER} +// Generated by interlinked-cli for the opencode2 binary. Talks to .interlinked/harness.sock. +// Do not edit by hand — re-run \`interlinked enable --clients opencode2\`. +import { createConnection } from "node:net"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const TIMEOUT_MS = 5000; +const MARKER = ${JSON.stringify(OPENCODE_PLUGIN_MARKER)}; +const PLUGIN_ID = ${JSON.stringify(OPENCODE_PLUGIN_ID)}; + +function isOpenCodeV2() { + const env = typeof process === "undefined" ? {} : process.env; + const argv = typeof process === "undefined" ? [] : process.argv; + if (Boolean(env.OPENCODE2) || env.INTERLINKED_CLIENT === "opencode2") return true; + if (String(env.XDG_CONFIG_HOME || "").includes("opencode-v2")) return true; + if (argv.some((part) => String(part).includes("opencode2"))) return true; + // This file is the v2 plugin. Gate unless the process is clearly OpenCode v1. + if (env.INTERLINKED_CLIENT === "opencode") return false; + if (env.OPENCODE && !env.OPENCODE2) return false; + return true; +} + +function asRecord(value) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) return value; + return {}; +} + +function str(obj, ...keys) { + for (const key of keys) { + const value = obj[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function mapTool(tool, args) { + const a = asRecord(args); + if (tool === "bash" || tool === "shell") { + const cwd = str(a, "workdir", "cwd"); + return { tool_name: "Bash", tool_input: { command: a.command, ...(cwd ? { cwd } : {}) } }; + } + if (tool === "edit") { + return { + tool_name: "Edit", + tool_input: { + file_path: str(a, "filePath", "file_path", "path") || "", + old_string: str(a, "oldString", "old_string") || "", + new_string: str(a, "newString", "new_string") || "", + ...(a.replaceAll === true || a.replace_all === true ? { replace_all: true } : {}), + }, + }; + } + if (tool === "write") { + return { + tool_name: "Write", + tool_input: { file_path: str(a, "filePath", "file_path", "path") || "", content: str(a, "content") || "" }, + }; + } + if (tool === "read") { + return { + tool_name: "Read", + tool_input: { + file_path: str(a, "filePath", "file_path", "path") || "", + ...(a.offset !== undefined ? { offset: a.offset } : {}), + ...(a.limit !== undefined ? { limit: a.limit } : {}), + }, + }; + } + if (tool === "apply_patch") { + return { tool_name: "apply_patch", tool_input: { patchText: str(a, "patchText", "patch_text") || "" } }; + } + if (tool === "grep") { + return { tool_name: "Grep", tool_input: { pattern: str(a, "pattern") || "", path: str(a, "path"), glob: str(a, "include", "glob") } }; + } + if (tool === "glob") { + return { tool_name: "Glob", tool_input: { pattern: str(a, "pattern") || "", path: str(a, "path") } }; + } + if (tool === "webfetch") { + return { tool_name: "WebFetch", tool_input: { url: str(a, "url") || "" } }; + } + if (tool === "task") { + return { + tool_name: "Task", + tool_input: { + description: str(a, "description") || "", + prompt: str(a, "prompt") || "", + subagent_type: str(a, "subagent_type", "agent") || "", + }, + }; + } + return { tool_name: tool, tool_input: a }; +} + +function findSocket(start) { + let dir = start || process.cwd(); + for (let i = 0; i < 24; i++) { + const candidate = join(dir, ".interlinked", "harness.sock"); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function coldBlock(toolName, toolInput) { + if (toolName !== "Bash") return null; + const command = typeof toolInput.command === "string" ? toolInput.command : ""; + if (/\\brm\\s+-[a-zA-Z]*r[a-zA-Z]*f\\b.*\\s(\\/|~)/.test(command)) { + return "BLOCKED: Recursive deletion of a filesystem root is not allowed (OpenCode cold fallback)."; + } + if (/\\b(npm|pnpm|yarn|bun|pip|pip3|pipx|poetry|uv|cargo|gem|bundle|go)\\s+(install|add|get)\\b/.test(command)) { + return "BLOCKED: Package installs require the Interlinked daemon (allowlist). Start it with \`interlinked harness start\`."; + } + return null; +} + +function queryHarness(event) { + const socketPath = findSocket(event.cwd); + if (!socketPath) { + const reason = coldBlock(event.tool_name, event.tool_input || {}); + return Promise.resolve(reason ? { decision: "block", reason } : { decision: "allow" }); + } + return new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket.destroy(); } catch { /* ignore */ } + resolve(value); + }; + const timer = setTimeout(() => { + const reason = coldBlock(event.tool_name, event.tool_input || {}); + finish(reason ? { decision: "block", reason } : { decision: "allow" }); + }, TIMEOUT_MS); + const socket = createConnection(socketPath); + let buf = ""; + socket.on("connect", () => socket.write(JSON.stringify(event) + "\\n")); + socket.on("data", (chunk) => { + buf += chunk.toString("utf8"); + const nl = buf.indexOf("\\n"); + if (nl === -1) return; + try { + finish(JSON.parse(buf.slice(0, nl))); + } catch { + finish({ decision: "allow" }); + } + }); + socket.on("error", () => { + const reason = coldBlock(event.tool_name, event.tool_input || {}); + finish(reason ? { decision: "block", reason } : { decision: "allow" }); + }); + socket.on("close", () => { + if (!settled) finish({ decision: "allow" }); + }); + }); +} + +function baseEvent(cwd, sessionId) { + return { + session_id: sessionId || "unknown", + agent_source: "opencode2", + agent_name: "opencode2", + cwd: cwd || process.cwd(), + timestamp: new Date().toISOString(), + }; +} + +function readArgs(event) { + const a = event && event.args; + if (a && typeof a === "object") { + if (typeof a.get === "function") return a.get(); + if (a.value !== undefined) return a.value; + } + return a || (event && event.input) || {}; +} + +function deny(event, reason) { + if (event && typeof event.block === "function") { + event.block(reason); + return; + } + if (event && typeof event.abort === "function") { + event.abort(reason); + return; + } + throw new Error(reason); +} + +async function gateBefore(event, pluginCwd) { + const tool = (event && (event.tool || event.name || event.id)) || "unknown"; + const mapped = mapTool(typeof tool === "string" ? tool : String(tool), readArgs(event)); + const sessionId = event && (event.sessionID || event.sessionId); + const decision = await queryHarness({ + ...baseEvent(pluginCwd, sessionId), + hook_event: "PreToolUse", + tool_name: mapped.tool_name, + tool_input: mapped.tool_input, + tool_use_id: event && event.callID, + }); + if (decision && (decision.decision === "block" || decision.decision === "ask")) { + deny(event, decision.reason || "Blocked by Interlinked"); + } +} + +async function gateAfter(event, pluginCwd) { + const tool = (event && (event.tool || event.name || event.id)) || "unknown"; + const mapped = mapTool(typeof tool === "string" ? tool : String(tool), readArgs(event)); + const sessionId = event && (event.sessionID || event.sessionId); + await queryHarness({ + ...baseEvent(pluginCwd, sessionId), + hook_event: "PostToolUse", + tool_name: mapped.tool_name, + tool_input: mapped.tool_input, + tool_response: event && (event.output || event.result), + tool_use_id: event && event.callID, + }); +} + +let armed = false; +function arm() { + if (armed) return false; + armed = true; + return true; +} + +export const InterlinkedPlugin = async ({ directory, worktree }) => { + void MARKER; + void worktree; + if (!isOpenCodeV2() || !arm()) return {}; + const pluginCwd = directory || process.cwd(); + return { + "tool.execute.before": async (input, output) => { + await gateBefore({ + tool: input && input.tool, + args: output && output.args, + sessionID: input && input.sessionID, + callID: input && input.callID, + }, pluginCwd); + }, + "tool.execute.after": async (input, output) => { + await gateAfter({ + tool: input && input.tool, + args: input && input.args, + output: output && output.output, + sessionID: input && input.sessionID, + callID: input && input.callID, + }, pluginCwd); + }, + }; +}; + +export default { + id: PLUGIN_ID, + setup: async (ctx) => { + void MARKER; + if (!isOpenCodeV2() || !arm()) return; + const pluginCwd = (ctx && ctx.location && ctx.location.directory) || process.cwd(); + if (ctx.tool && typeof ctx.tool.hook === "function") { + await ctx.tool.hook("execute.before", (event) => gateBefore(event, pluginCwd)); + await ctx.tool.hook("execute.after", (event) => gateAfter(event, pluginCwd)); + } + if (ctx.shell && typeof ctx.shell.hook === "function") { + await ctx.shell.hook("execute.before", (event) => { + const command = event && (event.command || readArgs(event).command); + return gateBefore({ tool: "bash", args: { command }, sessionID: event && event.sessionID }, pluginCwd); + }); + } + if (ctx.session && typeof ctx.session.hook === "function") { + await ctx.session.hook("created", (event) => + queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "SessionStart" }), + ); + await ctx.session.hook("idle", (event) => + queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "Stop" }), + ); + } + }, +}; +`; +} diff --git a/src/lib/opencode-runtime.ts b/src/lib/opencode-runtime.ts new file mode 100644 index 00000000..b170d3e8 --- /dev/null +++ b/src/lib/opencode-runtime.ts @@ -0,0 +1,10 @@ +// Shared OpenCode v1 vs v2 process detection. Both adapters and (as a +// copied snippet) the generated plugins use the same rules so v1 never +// claims an opencode2 process and the two plugins never double-gate. + +export function isOpenCodeV2Env(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.OPENCODE2) return true; + if (env.INTERLINKED_CLIENT === "opencode2") return true; + const xdg = env.XDG_CONFIG_HOME ?? ""; + return xdg.includes("opencode-v2"); +} diff --git a/src/lib/opencode-tool-map.test.ts b/src/lib/opencode-tool-map.test.ts new file mode 100644 index 00000000..308cfeaf --- /dev/null +++ b/src/lib/opencode-tool-map.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { mapOpencode2Tool, opencode2ColdBlockReason } from "./opencode-tool-map.js"; + +describe("mapOpencode2Tool — positive (must map)", () => { + it("P1: maps bash and shell command + workdir", () => { + expect(mapOpencode2Tool("bash", { command: "ls", workdir: "/tmp" })).toEqual({ + tool_name: "Bash", + tool_input: { command: "ls", cwd: "/tmp" }, + }); + expect(mapOpencode2Tool("shell", { command: "ls" }).tool_name).toBe("Bash"); + }); + + it("P2: maps edit camelCase fields to file_path/old_string/new_string", () => { + const mapped = mapOpencode2Tool("edit", { + filePath: "/r/a.ts", + oldString: "x", + newString: "y", + replaceAll: true, + }); + expect(mapped.tool_name).toBe("Edit"); + expect(mapped.tool_input).toMatchObject({ + file_path: "/r/a.ts", + old_string: "x", + new_string: "y", + replace_all: true, + }); + }); + + it("P3: maps write/read/grep/task", () => { + expect(mapOpencode2Tool("write", { filePath: "/r/a.ts", content: "hi" }).tool_name).toBe("Write"); + expect(mapOpencode2Tool("read", { filePath: "/r/a.ts" }).tool_name).toBe("Read"); + expect(mapOpencode2Tool("grep", { pattern: "foo", include: "*.ts" }).tool_input.glob).toBe("*.ts"); + expect(mapOpencode2Tool("task", { prompt: "do", agent: "engineer" }).tool_input.subagent_type).toBe( + "engineer", + ); + }); +}); + +describe("mapOpencode2Tool — negative (must not invent names)", () => { + it("N1: unknown tools keep their original name", () => { + expect(mapOpencode2Tool("skill", { name: "x" }).tool_name).toBe("skill"); + }); + + it("N2: non-object args become empty input", () => { + expect(mapOpencode2Tool("bash", "nope").tool_input).toEqual({}); + }); +}); + +describe("opencode2ColdBlockReason", () => { + it("P1: blocks recursive rm of /", () => { + expect(opencode2ColdBlockReason("Bash", { command: "rm -rf /" })).toMatch(/BLOCKED/); + }); + + it("P2: blocks npm install", () => { + expect(opencode2ColdBlockReason("Bash", { command: "npm install left-pad" })).toMatch(/BLOCKED/); + }); + + it("N1: allows ls", () => { + expect(opencode2ColdBlockReason("Bash", { command: "ls" })).toBeNull(); + }); + + it("N2: ignores non-bash tools", () => { + expect(opencode2ColdBlockReason("Edit", { command: "rm -rf /" })).toBeNull(); + }); +}); diff --git a/src/lib/opencode-tool-map.ts b/src/lib/opencode-tool-map.ts new file mode 100644 index 00000000..7bb5fed4 --- /dev/null +++ b/src/lib/opencode-tool-map.ts @@ -0,0 +1,131 @@ +// =========================================== +// OpenCode v2 (opencode2) tool names → Interlinked (Claude-shaped) tool_input +// =========================================== +// Shared by the installed v2 plugin (inlined) and the runner adapter. +// OpenCode tools are lowercase (`edit`, `bash`); the harness evaluator matches +// on Claude-style names (`Edit`, `Bash`) plus snake_case file_path fields. +// v2 (`opencode2`) bridge. + +import type { JsonObject } from "./json-types.js"; + +export interface MappedOpencodeTool { + tool_name: string; + tool_input: JsonObject; +} + +function asRecord(value: unknown): JsonObject { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return value as JsonObject; + } + return {}; +} + +function str(obj: JsonObject, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = obj[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function pick(obj: JsonObject, from: string, to: string): JsonObject { + const value = obj[from]; + return value === undefined ? {} : { [to]: value }; +} + +/** Map one OpenCode v2 tool invocation onto the harness's expected tool names. */ +export function mapOpencode2Tool(tool: string, args: unknown): MappedOpencodeTool { + const a = asRecord(args); + switch (tool) { + case "bash": + case "shell": + return { + tool_name: "Bash", + tool_input: { + ...pick(a, "command", "command"), + ...(str(a, "workdir", "cwd") ? { cwd: str(a, "workdir", "cwd") } : {}), + }, + }; + case "edit": + return { + tool_name: "Edit", + tool_input: { + file_path: str(a, "filePath", "file_path", "path") ?? "", + old_string: str(a, "oldString", "old_string") ?? "", + new_string: str(a, "newString", "new_string") ?? "", + ...(a.replaceAll === true || a.replace_all === true ? { replace_all: true } : {}), + }, + }; + case "write": + return { + tool_name: "Write", + tool_input: { + file_path: str(a, "filePath", "file_path", "path") ?? "", + content: str(a, "content") ?? "", + }, + }; + case "read": + return { + tool_name: "Read", + tool_input: { + file_path: str(a, "filePath", "file_path", "path") ?? "", + ...(a.offset !== undefined ? { offset: a.offset } : {}), + ...(a.limit !== undefined ? { limit: a.limit } : {}), + }, + }; + case "apply_patch": + return { + tool_name: "apply_patch", + tool_input: { patchText: str(a, "patchText", "patch_text") ?? "" }, + }; + case "grep": + return { + tool_name: "Grep", + tool_input: { + pattern: str(a, "pattern") ?? "", + path: str(a, "path"), + glob: str(a, "include", "glob"), + }, + }; + case "glob": + return { + tool_name: "Glob", + tool_input: { pattern: str(a, "pattern") ?? "", path: str(a, "path") }, + }; + case "webfetch": + return { + tool_name: "WebFetch", + tool_input: { url: str(a, "url") ?? "" }, + }; + case "task": + return { + tool_name: "Task", + tool_input: { + description: str(a, "description") ?? "", + prompt: str(a, "prompt") ?? "", + subagent_type: str(a, "subagent_type", "agent") ?? "", + }, + }; + default: + return { tool_name: tool, tool_input: a }; + } +} + +const DESTRUCTIVE_RM = /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f\b.*\s(\/|~)/; +const INSTALL_VERB = + /\b(npm|pnpm|yarn|bun|pip|pip3|pipx|poetry|uv|cargo|gem|bundle|go)\s+(install|add|get)\b/; + +/** Tiny fail-closed subset used when the daemon socket is unreachable (v2). */ +export function opencode2ColdBlockReason(toolName: string, toolInput: JsonObject): string | null { + if (toolName !== "Bash") return null; + const command = typeof toolInput.command === "string" ? toolInput.command : ""; + if (DESTRUCTIVE_RM.test(command)) { + return "BLOCKED: Recursive deletion of a filesystem root is not allowed (OpenCode cold fallback)."; + } + if (INSTALL_VERB.test(command)) { + return "BLOCKED: Package installs require the Interlinked daemon (allowlist). Start it with `interlinked harness start`."; + } + return null; +} + + diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 45834ce0..2742a5ee 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -19,6 +19,7 @@ export type ClientName = | "codex" | "cursor" | "opencode" + | "opencode2" | "pi"; interface ClientConfig { @@ -91,9 +92,21 @@ const CLIENT_CONFIGS: ClientConfig[] = [ detectFromEnv: (env) => Object.entries(env).some( ([name, value]) => - name.startsWith("OPENCODE") && typeof value === "string" && value.length > 0, + name.startsWith("OPENCODE") && + name !== "OPENCODE2" && + typeof value === "string" && + value.length > 0, ), }, + { + name: "opencode2", + label: "OpenCode v2", + configDir: ".opencode", + settingsFile: "plugins/interlinked-opencode2.ts", + inputMethod: "stdin", + detectFromEnv: (env) => + Boolean(env.OPENCODE2) || env.INTERLINKED_CLIENT === "opencode2", + }, { name: "pi", label: "Pi", @@ -149,6 +162,7 @@ export const CLIENT_TO_RUNNER: Record = { codex: "codex", cursor: "cursor", opencode: "opencode", + opencode2: "opencode2", pi: "pi", }; diff --git a/src/lib/skill-install-ownership.ts b/src/lib/skill-install-ownership.ts index b272c2a7..13bb6f03 100644 --- a/src/lib/skill-install-ownership.ts +++ b/src/lib/skill-install-ownership.ts @@ -54,6 +54,7 @@ const CLIENT_SKILL_ROOTS: Record = { codex: join(".agents", "skills"), cursor: join(".cursor", "skills"), opencode: join(".opencode", "skills"), + opencode2: join(".opencode", "skills"), pi: join(".pi", "skills"), }; diff --git a/src/lib/skill-install-templates.test.ts b/src/lib/skill-install-templates.test.ts index c8b0a689..43d63680 100644 --- a/src/lib/skill-install-templates.test.ts +++ b/src/lib/skill-install-templates.test.ts @@ -37,6 +37,9 @@ describe("skill install templates", () => { expect(runnerTargets("opencode", "interlinked", config)).toEqual([ { kind: "spec", relPath: ".opencode/skills/interlinked/SKILL.md" }, ]); + expect(runnerTargets("opencode2", "interlinked", config)).toEqual([ + { kind: "spec", relPath: ".opencode/skills/interlinked/SKILL.md" }, + ]); expect(runnerTargets("pi", "interlinked", config)).toEqual([ { kind: "spec", relPath: ".pi/skills/interlinked/SKILL.md" }, ]); diff --git a/src/lib/skill-install-templates.ts b/src/lib/skill-install-templates.ts index 03f461ed..bc8a5a36 100644 --- a/src/lib/skill-install-templates.ts +++ b/src/lib/skill-install-templates.ts @@ -33,6 +33,7 @@ export const RUNNERS_REQUIRING_SHORT_DESCRIPTION: ReadonlySet = new "copilot", "cursor", "opencode", + "opencode2", "pi", ]); @@ -112,6 +113,7 @@ export function runnerTargets( case "cursor": return [{ kind: "spec", relPath: join(".cursor", "skills", name, "SKILL.md") }]; case "opencode": + case "opencode2": return [{ kind: "spec", relPath: join(".opencode", "skills", name, "SKILL.md") }]; case "pi": return [{ kind: "spec", relPath: join(".pi", "skills", name, "SKILL.md") }]; diff --git a/src/lib/skill-installers.test.ts b/src/lib/skill-installers.test.ts index c42aff03..9ce2c431 100644 --- a/src/lib/skill-installers.test.ts +++ b/src/lib/skill-installers.test.ts @@ -312,6 +312,7 @@ describe("description transform for runners with strict limits", () => { ["copilot", ".github/skills/enforce/SKILL.md"], ["cursor", ".cursor/skills/enforce/SKILL.md"], ["opencode", ".opencode/skills/enforce/SKILL.md"], + ["opencode2", ".opencode/skills/enforce/SKILL.md"], ["pi", ".pi/skills/enforce/SKILL.md"], ] as const)("%s install keeps description under 1024 chars", (client, relPath) => { installEnforceSkill(tmpRoot, [client]); diff --git a/src/lib/viz/agent-roster.ts b/src/lib/viz/agent-roster.ts index e50c6bb2..a04db9e3 100644 --- a/src/lib/viz/agent-roster.ts +++ b/src/lib/viz/agent-roster.ts @@ -51,7 +51,7 @@ const FILES_KEPT = 6; const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit", "apply_patch"]); /** Runner families recognized in an agent name (`session--`). */ -const RUNNERS = ["claude", "codex", "copilot", "gemini", "cursor", "opencode", "pi", "aider"]; +const RUNNERS = ["claude", "codex", "copilot", "gemini", "cursor", "opencode", "opencode2", "pi", "aider"]; /** * Stable hue for an actor id: the same FNV-1a → hue mapping the graph uses for diff --git a/src/registrars/setup.mutation-kill.test.ts b/src/registrars/setup.mutation-kill.test.ts index 23756d9d..91ee5beb 100644 --- a/src/registrars/setup.mutation-kill.test.ts +++ b/src/registrars/setup.mutation-kill.test.ts @@ -95,7 +95,7 @@ describe("registerSetupCommands — public help contract", () => { "--server": "Server URL", "--agent": "Default agent name", "--clients": - "Comma-separated client list (claude,copilot,gemini,codex,cursor,opencode,pi)", + "Comma-separated client list (claude,copilot,gemini,codex,cursor,opencode,opencode2,pi)", "--sync-mode": "Sync mode: realtime (default), local, manual", "--data-dir": "Override data directory for activity logs and sessions", "--dry-run": "Show what would change without modifying files", @@ -141,7 +141,7 @@ describe("registerSetupCommands — public help contract", () => { "--server": "Server URL", "--agent": "Default agent name", "--clients": - "Comma-separated client list (claude,copilot,gemini,codex,cursor,opencode,pi)", + "Comma-separated client list (claude,copilot,gemini,codex,cursor,opencode,opencode2,pi)", "--sync-mode": "Sync mode: realtime (default), local, manual", "--token": "Manual token for CI/headless use", "--dry-run": "Show what would change without modifying files", @@ -158,7 +158,7 @@ describe("registerSetupCommands — public help contract", () => { description: "Install agent hooks for detected runners (adapter-based, manifest-driven)", options: { "--runner": - "Comma-separated runners (claude-code,copilot-cli,cursor,gemini-cli,codex,opencode,pi); defaults to auto-detect", + "Comma-separated runners (claude-code,copilot-cli,cursor,gemini-cli,codex,opencode,opencode2,pi); defaults to auto-detect", "--scope": "Install scope: user, project, or local", "--mode": "Enforcement preset: balanced, strict, lenient", "--binary": "Override path to the interlinked binary", diff --git a/src/registrars/setup.ts b/src/registrars/setup.ts index d4306ac5..af565c0b 100644 --- a/src/registrars/setup.ts +++ b/src/registrars/setup.ts @@ -25,9 +25,9 @@ interface ViewOpts extends JsonOpts { } const CLIENT_LIST_HELP = - "Comma-separated client list (claude,copilot,gemini,codex,cursor,opencode,pi)"; + "Comma-separated client list (claude,copilot,gemini,codex,cursor,opencode,opencode2,pi)"; const RUNNER_LIST_HELP = - "Comma-separated runners (claude-code,copilot-cli,cursor,gemini-cli,codex,opencode,pi); defaults to auto-detect"; + "Comma-separated runners (claude-code,copilot-cli,cursor,gemini-cli,codex,opencode,opencode2,pi); defaults to auto-detect"; async function setupAction(opts: OptionValues): Promise { await enableCommand({ From 12835c5eba9c4dc141417ac7fc6594e354794ca8 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:37:08 +0000 Subject: [PATCH 2/8] fix: keep OpenCode v2 plugin as .ts (user and project) The user plugin path is ~/.config/opencode/plugins/interlinked-opencode2.ts. The previous .js rename was a stale-service artifact; plugin list after restarting opencode2 loads the TypeScript plugin. --- src/harness/adapters/opencode2.test.ts | 2 +- src/harness/adapters/opencode2.ts | 2 +- src/lib/hook-installers-opencode.ts | 11 +++++------ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/harness/adapters/opencode2.test.ts b/src/harness/adapters/opencode2.test.ts index 058f51f3..4d769a93 100644 --- a/src/harness/adapters/opencode2.test.ts +++ b/src/harness/adapters/opencode2.test.ts @@ -15,7 +15,7 @@ describe("OpenCode adapter identity", () => { expect(project.path).toBe(".opencode/plugins/interlinked-opencode2.ts"); expect(project.fileContent).toContain("interlinked-opencode2"); expect(adapter.renderSettingsFragment("/bin/hook", "user").path).toBe( - "~/.config/opencode-v2/opencode/plugins/interlinked-opencode2.ts", + "~/.config/opencode/plugins/interlinked-opencode2.ts", ); }); }); diff --git a/src/harness/adapters/opencode2.ts b/src/harness/adapters/opencode2.ts index c7098d37..06d51ade 100644 --- a/src/harness/adapters/opencode2.ts +++ b/src/harness/adapters/opencode2.ts @@ -116,7 +116,7 @@ export function createOpencode2Adapter(opts: OpencodeAdapterOptions = {}): Runne renderSettingsFragment(_binaryPath, scope): SettingsFragment { const path = scope === "user" - ? `~/.config/opencode-v2/opencode/plugins/${OPENCODE_PLUGIN_FILENAME}` + ? `~/.config/opencode/plugins/${OPENCODE_PLUGIN_FILENAME}` : `.opencode/plugins/${OPENCODE_PLUGIN_FILENAME}`; return { path, diff --git a/src/lib/hook-installers-opencode.ts b/src/lib/hook-installers-opencode.ts index d7476475..1ff9c5af 100644 --- a/src/lib/hook-installers-opencode.ts +++ b/src/lib/hook-installers-opencode.ts @@ -31,8 +31,7 @@ export function opencodePluginRelPath(): string { export function getOpencodePluginPath(cwd: string, scope: "user" | "project" | "local" = "project"): string { if (scope === "user") { const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); - // opencode2's `plugin list` loads ~/.config/opencode/plugins/*.js - return join(home, ".config", "opencode", "plugins", OPENCODE_PLUGIN_FILENAME.replace(/\.ts$/, ".js")); + return join(home, ".config", "opencode", "plugins", OPENCODE_PLUGIN_FILENAME); } return join(cwd, ".opencode", "plugins", OPENCODE_PLUGIN_FILENAME); } @@ -55,7 +54,7 @@ function pluginPaths(cwd: string): string[] { join(projectDir, "interlinked.js"), join(projectDir, "interlinked.ts"), join(v1UserDir, "interlinked.js"), - join(v1UserDir, OPENCODE_PLUGIN_FILENAME), + join(v1UserDir, "interlinked-opencode2.js"), join(v1UserDir, "interlinked.ts"), ]; } @@ -75,11 +74,11 @@ function pluginIsOurs(path: string): boolean { */ export function installOpencode2Hooks(cwd: string, _hookScriptPath: string): void { const project = getOpencodePluginPath(cwd, "project"); - const userJs = getOpencodePluginPath(cwd, "user"); + const userPlugin = getOpencodePluginPath(cwd, "user"); writePlugin(project); - writePlugin(userJs); + writePlugin(userPlugin); for (const leftover of pluginPaths(cwd)) { - if (leftover === project || leftover === userJs) continue; + if (leftover === project || leftover === userPlugin) continue; if (!pluginIsOurs(leftover)) continue; rmSync(leftover, { force: true }); } From 64d647c316a1d2e4a18a4f887ee16e837bb10d76 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:35:52 +0000 Subject: [PATCH 3/8] fix: close OpenCode v2 cold-fallback rm -fr hole and emit SessionEnd Cold fallback now matches rm -rf and rm -fr of / or ~. The generated plugin hooks session.deleted as SessionEnd. User plugin path in the setup skill matches ~/.config/opencode/plugins/interlinked-opencode2.ts. --- skills/interlinked-setup/SKILL.md | 2 +- src/lib/hook-installers-opencode.test.ts | 3 +++ src/lib/opencode-plugin-source.ts | 7 ++++++- src/lib/opencode-tool-map.test.ts | 2 ++ src/lib/opencode-tool-map.ts | 5 +++-- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/skills/interlinked-setup/SKILL.md b/skills/interlinked-setup/SKILL.md index df045101..003aba51 100644 --- a/skills/interlinked-setup/SKILL.md +++ b/skills/interlinked-setup/SKILL.md @@ -471,7 +471,7 @@ interlinked logout [--all] - **OpenCode and Pi installs are managed source bridges.** OpenCode v1 writes `.opencode/plugins/interlinked.ts` (user: `~/.config/opencode/plugins/interlinked.ts`). OpenCode v2 (`opencode2`) writes `.opencode/plugins/interlinked-opencode2.ts` (user: - `~/.config/opencode-v2/opencode/plugins/`). Distinct filenames and plugin ids, so both + `~/.config/opencode/plugins/interlinked-opencode2.ts`). Distinct filenames and plugin ids, so both can be enabled in one repo; each plugin no-ops in the other binary. Pi writes `.pi/extensions/interlinked.js`. Install refuses to overwrite a foreign file at that path, and uninstall preserves a bridge whose bytes changed after install. Restart OpenCode diff --git a/src/lib/hook-installers-opencode.test.ts b/src/lib/hook-installers-opencode.test.ts index da39c3d9..3c7a2fd4 100644 --- a/src/lib/hook-installers-opencode.test.ts +++ b/src/lib/hook-installers-opencode.test.ts @@ -37,6 +37,9 @@ describe("OpenCode plugin installer", () => { expect(body).toContain("export const InterlinkedPlugin"); expect(body).toContain("setup:"); expect(body).toContain("execute.before"); + expect(body).toContain('hook("deleted"'); + expect(body).toContain("SessionEnd"); + expect(body).toContain("(?:r[a-zA-Z]*f|f[a-zA-Z]*r)"); expect(isOpencode2PluginInstalled(cwd)).toBe(true); }); diff --git a/src/lib/opencode-plugin-source.ts b/src/lib/opencode-plugin-source.ts index aaf347b2..9fe8bcaf 100644 --- a/src/lib/opencode-plugin-source.ts +++ b/src/lib/opencode-plugin-source.ts @@ -6,6 +6,8 @@ // colliding on filename or plugin id. OpenCode auto-loads // `{plugin,plugins}/*.{ts,js}`. The file has ZERO imports from this package. +import { OPENCODE2_DESTRUCTIVE_RM } from "./opencode-tool-map.js"; + /** Marker grepped by doctor / uninstall. Distinct from the v1 provider-bridge marker. */ export const OPENCODE_PLUGIN_MARKER = "interlinked-opencode-plugin"; @@ -124,7 +126,7 @@ function findSocket(start) { function coldBlock(toolName, toolInput) { if (toolName !== "Bash") return null; const command = typeof toolInput.command === "string" ? toolInput.command : ""; - if (/\\brm\\s+-[a-zA-Z]*r[a-zA-Z]*f\\b.*\\s(\\/|~)/.test(command)) { + if (/${OPENCODE2_DESTRUCTIVE_RM.source}/.test(command)) { return "BLOCKED: Recursive deletion of a filesystem root is not allowed (OpenCode cold fallback)."; } if (/\\b(npm|pnpm|yarn|bun|pip|pip3|pipx|poetry|uv|cargo|gem|bundle|go)\\s+(install|add|get)\\b/.test(command)) { @@ -289,6 +291,9 @@ export default { await ctx.session.hook("created", (event) => queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "SessionStart" }), ); + await ctx.session.hook("deleted", (event) => + queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "SessionEnd" }), + ); await ctx.session.hook("idle", (event) => queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "Stop" }), ); diff --git a/src/lib/opencode-tool-map.test.ts b/src/lib/opencode-tool-map.test.ts index 308cfeaf..97a00440 100644 --- a/src/lib/opencode-tool-map.test.ts +++ b/src/lib/opencode-tool-map.test.ts @@ -49,6 +49,8 @@ describe("mapOpencode2Tool — negative (must not invent names)", () => { describe("opencode2ColdBlockReason", () => { it("P1: blocks recursive rm of /", () => { expect(opencode2ColdBlockReason("Bash", { command: "rm -rf /" })).toMatch(/BLOCKED/); + expect(opencode2ColdBlockReason("Bash", { command: "rm -fr /" })).toMatch(/BLOCKED/); + expect(opencode2ColdBlockReason("Bash", { command: "rm -fr ~" })).toMatch(/BLOCKED/); }); it("P2: blocks npm install", () => { diff --git a/src/lib/opencode-tool-map.ts b/src/lib/opencode-tool-map.ts index 7bb5fed4..c3b2024a 100644 --- a/src/lib/opencode-tool-map.ts +++ b/src/lib/opencode-tool-map.ts @@ -111,7 +111,8 @@ export function mapOpencode2Tool(tool: string, args: unknown): MappedOpencodeToo } } -const DESTRUCTIVE_RM = /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f\b.*\s(\/|~)/; +/** Recursive rm of / or ~. Matches `-rf` and `-fr` (and mixed clusters containing both). */ +export const OPENCODE2_DESTRUCTIVE_RM = /\brm\s+-[a-zA-Z]*(?:r[a-zA-Z]*f|f[a-zA-Z]*r)\b.*\s(\/|~)/; const INSTALL_VERB = /\b(npm|pnpm|yarn|bun|pip|pip3|pipx|poetry|uv|cargo|gem|bundle|go)\s+(install|add|get)\b/; @@ -119,7 +120,7 @@ const INSTALL_VERB = export function opencode2ColdBlockReason(toolName: string, toolInput: JsonObject): string | null { if (toolName !== "Bash") return null; const command = typeof toolInput.command === "string" ? toolInput.command : ""; - if (DESTRUCTIVE_RM.test(command)) { + if (OPENCODE2_DESTRUCTIVE_RM.test(command)) { return "BLOCKED: Recursive deletion of a filesystem root is not allowed (OpenCode cold fallback)."; } if (INSTALL_VERB.test(command)) { From aad46e3d6086985ea32a29ad4b99be364bb6346b Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:44:14 +0000 Subject: [PATCH 4/8] test: pin install artifacts, rm -fr cold fallback, and documented hook paths Cross-runner contract: every adapter's installed events must appear in the settings fragment or plugin source; project_hook_path must match the fragment; skill hook-file paths must resolve to an adapter; every cold rm detector must block both -rf and -fr. Wire OpenCode v2 into the capability catalog so that list cannot drift again. --- src/harness/adapters/install-contract.test.ts | 124 ++++++++++++++++++ src/harness/adapters/opencode2.ts | 16 +-- .../adapters/provider-capabilities.test.ts | 6 +- src/harness/adapters/provider-capabilities.ts | 25 ++++ 4 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/harness/adapters/install-contract.test.ts diff --git a/src/harness/adapters/install-contract.test.ts b/src/harness/adapters/install-contract.test.ts new file mode 100644 index 00000000..39b2899f --- /dev/null +++ b/src/harness/adapters/install-contract.test.ts @@ -0,0 +1,124 @@ +// Cross-runner install contract. +// Catches the class of bugs Copilot found on OpenCode v2: advertised events +// that the install artifact never registers, documented paths that drift from +// renderSettingsFragment, and cold `rm -rf` detectors that miss `rm -fr`. + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { checkDestructiveCommand } from "../../lib/hook-template-chunks/destructive-command-guard.js"; +import { opencode2ColdBlockReason } from "../../lib/opencode-tool-map.js"; +import { buildAllAdapters } from "./index.js"; +import { installedEventNames } from "./provider-capabilities.js"; + +const REPO_ROOT = join(import.meta.dirname, "..", "..", ".."); + +function walkTs(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name === "dist" || name === ".git") continue; + const path = join(dir, name); + const st = statSync(path); + if (st.isDirectory()) walkTs(path, out); + else if (name.endsWith(".ts")) out.push(path); + } + return out; +} + +function artifactHaystack(adapterId: string): string { + const adapter = buildAllAdapters().find((a) => a.id === adapterId); + if (!adapter) throw new Error(`missing adapter ${adapterId}`); + const frag = adapter.renderSettingsFragment("/bin/hook", "project"); + return `${frag.fileContent ?? ""}\n${JSON.stringify(frag.fragment)}`; +} + +/** Needles that prove an installed event is actually registered, not just listed. */ +function eventNeedles(name: string): string[] { + const full = name.startsWith("event:") ? name.slice("event:".length) : name; + const leaf = full.includes(".") ? full.slice(full.lastIndexOf(".") + 1) : full; + return [full, `hook("${leaf}"`, `"${full}":`]; +} + +function artifactContainsEvent(haystack: string, name: string): boolean { + return eventNeedles(name).some((needle) => haystack.includes(needle)); +} + +const COLD_BLOCKERS: Array<{ id: string; blocks: (cmd: string) => boolean }> = [ + { + id: "checkDestructiveCommand", + blocks: (cmd) => checkDestructiveCommand(cmd) !== null, + }, + { + id: "opencode2ColdBlockReason", + blocks: (cmd) => opencode2ColdBlockReason("Bash", { command: cmd }) !== null, + }, +]; + +describe("every runner's install artifact registers every installed event", () => { + it("nativeEventNames is the capability install list, and each name is in the fragment or plugin source", () => { + for (const adapter of buildAllAdapters()) { + const installed = installedEventNames(adapter.capabilities); + expect(adapter.nativeEventNames, adapter.id).toEqual(installed); + const haystack = artifactHaystack(adapter.id); + for (const name of installed) { + expect( + artifactContainsEvent(haystack, name), + `${adapter.id} install artifact missing ${name}`, + ).toBe(true); + } + } + }); +}); + +describe("documented hook paths match renderSettingsFragment", () => { + it("capabilities.project_hook_path is the project fragment path", () => { + for (const adapter of buildAllAdapters()) { + expect(adapter.capabilities.project_hook_path, adapter.id).toBe( + adapter.renderSettingsFragment("/bin/hook", "project").path, + ); + } + }); + + it("setup-skill hook-file paths resolve to an adapter fragment path", () => { + const known = new Set(); + for (const adapter of buildAllAdapters()) { + known.add(adapter.capabilities.project_hook_path); + known.add(adapter.renderSettingsFragment("/bin/hook", "project").path); + known.add(adapter.renderSettingsFragment("/bin/hook", "user").path); + } + const skill = readFileSync(join(REPO_ROOT, "skills", "interlinked-setup", "SKILL.md"), "utf8"); + const mentioned = skill.match( + /`(?:\.|~\/)[^`]*(?:plugins|hooks\.json|settings\.json|extensions)[^`]*`/g, + ) ?? []; + const unknown = mentioned + .map((tick) => tick.slice(1, -1)) + .filter((path) => ![...known].some((k) => path === k || path.endsWith(k) || k.endsWith(path))); + expect(unknown).toEqual([]); + }); +}); + +describe("cold recursive-rm detectors", () => { + it("every cold blocker refuses both -rf and -fr of / and ~", () => { + const mustBlock = ["rm -rf /", "rm -fr /", "rm -fr ~", "rm -rf ~"]; + const mustAllow = ["ls", "pwd"]; + for (const { id, blocks } of COLD_BLOCKERS) { + for (const cmd of mustBlock) { + expect(blocks(cmd), `${id} must block ${cmd}`).toBe(true); + } + for (const cmd of mustAllow) { + expect(blocks(cmd), `${id} must allow ${cmd}`).toBe(false); + } + } + }); + + it("any source that encodes rm -rf flag order also encodes rm -fr", () => { + const rfOnly = /rm\\s\+-\[a-zA-Z\]\*r\[a-zA-Z\]\*f/; + const frAlt = /f\[a-zA-Z\]\*r/; + const failures: string[] = []; + for (const file of walkTs(join(REPO_ROOT, "src"))) { + if (file.endsWith(".test.ts")) continue; + const text = readFileSync(file, "utf8"); + if (rfOnly.test(text) && !frAlt.test(text)) failures.push(file); + } + expect(failures).toEqual([]); + }); +}); diff --git a/src/harness/adapters/opencode2.ts b/src/harness/adapters/opencode2.ts index 06d51ade..64797f4c 100644 --- a/src/harness/adapters/opencode2.ts +++ b/src/harness/adapters/opencode2.ts @@ -22,20 +22,10 @@ import { } from "../tool-class-classifier.js"; import type { UnifiedPhase } from "../unified-event.js"; import { makeEventId } from "../unified-event.js"; +import { installedEventNames, OPENCODE2_CAPABILITIES } from "./provider-capabilities.js"; import type { AdapterOutput, PostInstallOptions, RunnerAdapter, SettingsFragment } from "./types.js"; -const NATIVE_EVENTS = [ - "tool.execute.before", - "tool.execute.after", - "session.created", - "session.deleted", - "session.idle", - "PreToolUse", - "PostToolUse", - "SessionStart", - "SessionEnd", - "Stop", -] as const; +const NATIVE_EVENTS = installedEventNames(OPENCODE2_CAPABILITIES); const PHASE_MAP: Record = { "tool.execute.before": "pre-tool", @@ -58,6 +48,8 @@ export function createOpencode2Adapter(opts: OpencodeAdapterOptions = {}): Runne return { id: "opencode2", label: "OpenCode v2", + experimental: true, + capabilities: OPENCODE2_CAPABILITIES, nativeEventNames: NATIVE_EVENTS, detectFromEnv(env) { diff --git a/src/harness/adapters/provider-capabilities.test.ts b/src/harness/adapters/provider-capabilities.test.ts index b4adb467..f45bc213 100644 --- a/src/harness/adapters/provider-capabilities.test.ts +++ b/src/harness/adapters/provider-capabilities.test.ts @@ -9,6 +9,7 @@ import { GEMINI_CLI_CAPABILITIES, installedEventNames, OPENCODE_CAPABILITIES, + OPENCODE2_CAPABILITIES, PI_CAPABILITIES, } from "./provider-capabilities.js"; @@ -19,12 +20,15 @@ const catalogs = [ GEMINI_CLI_CAPABILITIES, CURSOR_CAPABILITIES, OPENCODE_CAPABILITIES, + OPENCODE2_CAPABILITIES, PI_CAPABILITIES, ]; describe("provider capability catalog", () => { it("drives every adapter's installed native event list", () => { - for (const adapter of buildAllAdapters()) { + const adapters = buildAllAdapters(); + expect(catalogs).toHaveLength(adapters.length); + for (const adapter of adapters) { expect(adapter.nativeEventNames).toEqual(installedEventNames(adapter.capabilities)); } }); diff --git a/src/harness/adapters/provider-capabilities.ts b/src/harness/adapters/provider-capabilities.ts index 10eb6983..0e6d0035 100644 --- a/src/harness/adapters/provider-capabilities.ts +++ b/src/harness/adapters/provider-capabilities.ts @@ -231,6 +231,31 @@ export const OPENCODE_CAPABILITIES = defineCapabilities({ ], }); +export const OPENCODE2_CAPABILITIES = defineCapabilities({ + project_hook_path: ".opencode/plugins/interlinked-opencode2.ts", + hook_trust: "implicit", + status_line: "none", + events: [ + event("tool.execute.before", "pre-tool", { + control: "deny", + modelContext: true, + missingRuntime: "fail_closed", + }), + event("tool.execute.after", "post-tool", { + control: "continue", + modelContext: true, + }), + event("session.created", "session-start"), + event("session.deleted", "session-end"), + event("session.idle", "stop"), + event("PreToolUse", "pre-tool", { install: false, control: "deny" }), + event("PostToolUse", "post-tool", { install: false, control: "continue" }), + event("SessionStart", "session-start", { install: false }), + event("SessionEnd", "session-end", { install: false }), + event("Stop", "stop", { install: false }), + ], +}); + export const PI_CAPABILITIES = defineCapabilities({ project_hook_path: ".pi/extensions/interlinked.js", hook_trust: "definition-review", From 97fe15c15986fb5eaceaf3dfac5033fd01dc2580 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:37 +0000 Subject: [PATCH 5/8] fix: close OpenCode v2 merge blockers from review Keep Edit/Write casing so write gates fire. Fail closed on malformed or truncated daemon replies. Identify v2 only via OPENCODE2 / INTERLINKED_CLIENT. Install a single managed plugin file (no dual home write). Subscribe to session lifecycle through event.subscribe. PostToolUse only on mutating tools and append warnings. Detect v2 only with an explicit signal. Honor XDG_CONFIG_HOME. Match opencode2 in the roster by longest token. Execute the generated plugin against a fake host. Document experimental status, CONSUMED_PAYLOAD_KEYS, and PROVIDER_BY_SOURCE opencode2. --- docs/architecture.md | 5 +- skills/interlinked-setup/SKILL.md | 6 +- src/harness/adapters/index.test.ts | 12 ++ src/harness/adapters/opencode.ts | 3 +- src/harness/adapters/opencode2.test.ts | 6 +- src/harness/adapters/opencode2.ts | 17 +-- src/harness/payload-key-census.ts | 13 ++ .../agent-event-capture.mutation-kill.test.ts | 3 + src/harness/server/agent-event-capture.ts | 1 + src/lib/hook-installers-opencode.test.ts | 7 +- src/lib/hook-installers-opencode.ts | 22 +-- src/lib/opencode-plugin-runtime.test.ts | 139 ++++++++++++++++++ src/lib/opencode-plugin-source.ts | 75 +++++----- src/lib/opencode-runtime.test.ts | 56 +++++++ src/lib/opencode-runtime.ts | 28 +++- src/lib/opencode-tool-map.ts | 2 - src/lib/settings.ts | 18 ++- src/lib/viz/agent-roster.test.ts | 1 + src/lib/viz/agent-roster.ts | 2 +- 19 files changed, 323 insertions(+), 93 deletions(-) create mode 100644 src/lib/opencode-plugin-runtime.test.ts create mode 100644 src/lib/opencode-runtime.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 15663674..c9dce6d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -168,6 +168,7 @@ a common shape: | Copilot CLI (experimental) | 6 events | `.github/hooks/hooks.json` | | Gemini CLI (experimental) | 9 events | `.gemini/settings.json` hooks | | OpenCode (experimental) | 11 installed plugin callbacks; stable permission/Stop signals are observation- or deny-only | managed `.opencode/plugins/interlinked.ts` | +| OpenCode v2 (`opencode2`, experimental) | 5 plugin hooks (`tool.execute.before/after`, `session.created/deleted/idle` via `event.subscribe`); ask collapses to deny | managed `.opencode/plugins/interlinked-opencode2.ts` | | Pi (experimental) | 13 extension callbacks, including `tool_call`, `tool_result`, and direct `user_bash` | managed `.pi/extensions/interlinked.js` | Runtime reporting derives from the adapters' `nativeEventNames` lists @@ -175,8 +176,8 @@ Runtime reporting derives from the adapters' `nativeEventNames` lists pin expected SNAPSHOTS of those lists so drift is visible, and they carry their date for the same reason. Claude Code and Codex are the supported provider-contract pair. Copilot, Gemini, and Cursor remain experimental because their end-to-end provider contracts are not -proven. OpenCode and Pi have managed-bridge execution tests, but also remain experimental: -their upstream extension APIs expose a narrower native control surface than Claude/Codex. +proven. OpenCode, OpenCode v2, and Pi have managed-bridge execution tests, but also remain experimental: +their upstream extension APIs expose a narrower native control surface than Claude/Codex. OpenCode v2 stays experimental until a real-host acceptance test passes. All adapters share the capability and envelope layer described in [`docs/design/cli-hook-normalization.md`](design/cli-hook-normalization.md). Unknown diff --git a/skills/interlinked-setup/SKILL.md b/skills/interlinked-setup/SKILL.md index 003aba51..ccc4423c 100644 --- a/skills/interlinked-setup/SKILL.md +++ b/skills/interlinked-setup/SKILL.md @@ -470,9 +470,9 @@ interlinked logout [--all] current `gemini` client installs Gemini CLI hooks/skills; do not treat it as Antigravity. - **OpenCode and Pi installs are managed source bridges.** OpenCode v1 writes `.opencode/plugins/interlinked.ts` (user: `~/.config/opencode/plugins/interlinked.ts`). - OpenCode v2 (`opencode2`) writes `.opencode/plugins/interlinked-opencode2.ts` (user: - `~/.config/opencode/plugins/interlinked-opencode2.ts`). Distinct filenames and plugin ids, so both - can be enabled in one repo; each plugin no-ops in the other binary. Pi writes + OpenCode v2 (`opencode2`, experimental) writes `.opencode/plugins/interlinked-opencode2.ts` (user: + `~/.config/opencode/plugins/interlinked-opencode2.ts`, or `$XDG_CONFIG_HOME/opencode/plugins/` when set). Distinct filenames and plugin ids, so both + can be enabled in one repo with `--clients opencode,opencode2`; bare enable does not install v2 from `.opencode/` alone. Each plugin no-ops in the other binary. Pi writes `.pi/extensions/interlinked.js`. Install refuses to overwrite a foreign file at that path, and uninstall preserves a bridge whose bytes changed after install. Restart OpenCode after install. In Pi, run `/reload` or restart and approve the project-extension trust prompt. diff --git a/src/harness/adapters/index.test.ts b/src/harness/adapters/index.test.ts index 48c410f5..cf9cfae9 100644 --- a/src/harness/adapters/index.test.ts +++ b/src/harness/adapters/index.test.ts @@ -147,6 +147,18 @@ describe("cross-runner equivalence — semantically identical Edit events", () = "PreToolUse", ), ], + [ + "opencode2", + nonNull(buildAllAdapters()[5]).parseHookInput( + { + sessionID: "s", + cwd: "/r", + tool: "edit", + args: { filePath: "/r/a.ts", oldString: "x", newString: "y" }, + }, + "tool.execute.before", + ), + ], ]; for (const [name, event] of runners) { it(`${name} Edit → modify tool_class`, () => { diff --git a/src/harness/adapters/opencode.ts b/src/harness/adapters/opencode.ts index b17bffc6..bfbe58b0 100644 --- a/src/harness/adapters/opencode.ts +++ b/src/harness/adapters/opencode.ts @@ -198,8 +198,7 @@ export function renderOpenCodeBridgeSource(binaryPath: string): string { OPENCODE_PLUGIN_HELPERS, "function interlinkedIsOpenCodeV2() {", ' const env = typeof process === "undefined" ? {} : process.env;', - ' const argv = typeof process === "undefined" ? [] : process.argv;', - ' return Boolean(env.OPENCODE2) || env.INTERLINKED_CLIENT === "opencode2" || String(env.XDG_CONFIG_HOME || "").includes("opencode-v2") || argv.some((part) => String(part).includes("opencode2"));', + ' return env.INTERLINKED_CLIENT === "opencode2" || Boolean(env.OPENCODE2);', "}", "export const InterlinkedPlugin = async ({ directory, worktree }) => {", " if (interlinkedIsOpenCodeV2()) return {};", diff --git a/src/harness/adapters/opencode2.test.ts b/src/harness/adapters/opencode2.test.ts index 4d769a93..0a88673b 100644 --- a/src/harness/adapters/opencode2.test.ts +++ b/src/harness/adapters/opencode2.test.ts @@ -24,8 +24,8 @@ describe("OpenCode detectFromEnv", () => { it("P1: detects OPENCODE2", () => { expect(adapter.detectFromEnv({ OPENCODE2: "1" })).toBe(true); }); - it("P2: detects the v2 XDG config home", () => { - expect(adapter.detectFromEnv({ XDG_CONFIG_HOME: "/home/agent/.config/opencode-v2" })).toBe(true); + it("N3: XDG_CONFIG_HOME substring is not a v2 identity signal", () => { + expect(adapter.detectFromEnv({ XDG_CONFIG_HOME: "/home/agent/.config/opencode-v2" })).toBe(false); }); it("N1: ignores a plain env", () => { expect(adapter.detectFromEnv({})).toBe(false); @@ -44,7 +44,7 @@ describe("OpenCode parseHookInput", () => { expect(event.phase).toBe("pre-tool"); expect(event.runner).toBe("opencode2"); if (event.action.kind !== "tool_call") throw new Error("expected tool_call"); - expect(event.action.tool_name).toBe("edit"); + expect(event.action.tool_name).toBe("Edit"); expect(event.action.tool_input).toMatchObject({ file_path: "/r/a.ts" }); }); diff --git a/src/harness/adapters/opencode2.ts b/src/harness/adapters/opencode2.ts index 64797f4c..f174c161 100644 --- a/src/harness/adapters/opencode2.ts +++ b/src/harness/adapters/opencode2.ts @@ -12,8 +12,7 @@ // is ever invoked with --runner opencode2. import { mapOpencode2Tool } from "../../lib/opencode-tool-map.js"; -import { isOpenCodeV2Env } from "../../lib/opencode-runtime.js"; -import { installOpencode2Hooks, uninstallOpencode2Hooks } from "../../lib/hook-installers-opencode.js"; +import { isOpenCodeV2Env, opencodeUserPluginRelPath } from "../../lib/opencode-runtime.js"; import { buildOpencodePluginSource, OPENCODE_PLUGIN_FILENAME } from "../../lib/opencode-plugin-source.js"; import type { JsonObject } from "../../lib/json-types.js"; import { @@ -23,7 +22,7 @@ import { import type { UnifiedPhase } from "../unified-event.js"; import { makeEventId } from "../unified-event.js"; import { installedEventNames, OPENCODE2_CAPABILITIES } from "./provider-capabilities.js"; -import type { AdapterOutput, PostInstallOptions, RunnerAdapter, SettingsFragment } from "./types.js"; +import type { AdapterOutput, RunnerAdapter, SettingsFragment } from "./types.js"; const NATIVE_EVENTS = installedEventNames(OPENCODE2_CAPABILITIES); @@ -72,7 +71,7 @@ export function createOpencode2Adapter(opts: OpencodeAdapterOptions = {}): Runne phase === "pre-tool" || phase === "post-tool" ? { kind: "tool_call" as const, - tool_name: toolName.toLowerCase(), + tool_name: toolName, tool_class: classifyFromToolName( toolName, toolInput, @@ -108,7 +107,7 @@ export function createOpencode2Adapter(opts: OpencodeAdapterOptions = {}): Runne renderSettingsFragment(_binaryPath, scope): SettingsFragment { const path = scope === "user" - ? `~/.config/opencode/plugins/${OPENCODE_PLUGIN_FILENAME}` + ? opencodeUserPluginRelPath(OPENCODE_PLUGIN_FILENAME) : `.opencode/plugins/${OPENCODE_PLUGIN_FILENAME}`; return { path, @@ -139,15 +138,7 @@ export function createOpencode2Adapter(opts: OpencodeAdapterOptions = {}): Runne }; }, - postInstall(options: PostInstallOptions) { - if (options.dryRun) return; - installOpencode2Hooks(options.cwd, ""); - }, - postUninstall(options: PostInstallOptions) { - if (options.dryRun) return; - uninstallOpencode2Hooks(options.cwd); - }, }; } diff --git a/src/harness/payload-key-census.ts b/src/harness/payload-key-census.ts index 649ef61c..49759306 100644 --- a/src/harness/payload-key-census.ts +++ b/src/harness/payload-key-census.ts @@ -92,6 +92,19 @@ export const CONSUMED_PAYLOAD_KEYS: ReadonlySet = new Set([ "trigger", "custom_instructions", "files_modified", + // OpenCode v2 plugin / adapter payload aliases + "sessionID", + "callID", + "tool", + "args", + "output", + "filePath", + "oldString", + "newString", + "replaceAll", + "workdir", + "include", + "agent", ]); /** One census entry: the unconsumed keys seen for a runner + native event, diff --git a/src/harness/server/agent-event-capture.mutation-kill.test.ts b/src/harness/server/agent-event-capture.mutation-kill.test.ts index 4295d9bb..af5df9e9 100644 --- a/src/harness/server/agent-event-capture.mutation-kill.test.ts +++ b/src/harness/server/agent-event-capture.mutation-kill.test.ts @@ -523,6 +523,9 @@ describe("module-level lookup tables (AGENT_EVENT_NAMES / PROVIDER_BY_SOURCE)", ["gemini", "gemini-cli"], ["codex", "codex"], ["cursor", "cursor"], + ["opencode", "opencode"], + ["opencode2", "opencode2"], + ["pi", "pi"], ] as const)("maps agent_source %s to provider %s", (source, expectedProvider) => { const rec = buildAgentEventRecord(mkEvent({ agent_source: source }), "subagent_start", "/fallback"); expect(rec.provider).toBe(expectedProvider); diff --git a/src/harness/server/agent-event-capture.ts b/src/harness/server/agent-event-capture.ts index 3df0865c..38911647 100644 --- a/src/harness/server/agent-event-capture.ts +++ b/src/harness/server/agent-event-capture.ts @@ -73,6 +73,7 @@ const PROVIDER_BY_SOURCE: Record = { codex: "codex", cursor: "cursor", opencode: "opencode", + opencode2: "opencode2", pi: "pi", }; diff --git a/src/lib/hook-installers-opencode.test.ts b/src/lib/hook-installers-opencode.test.ts index 3c7a2fd4..a1edabd8 100644 --- a/src/lib/hook-installers-opencode.test.ts +++ b/src/lib/hook-installers-opencode.test.ts @@ -37,8 +37,9 @@ describe("OpenCode plugin installer", () => { expect(body).toContain("export const InterlinkedPlugin"); expect(body).toContain("setup:"); expect(body).toContain("execute.before"); - expect(body).toContain('hook("deleted"'); + expect(body).toContain("session.deleted"); expect(body).toContain("SessionEnd"); + expect(body).toContain("event.subscribe"); expect(body).toContain("(?:r[a-zA-Z]*f|f[a-zA-Z]*r)"); expect(isOpencode2PluginInstalled(cwd)).toBe(true); }); @@ -58,10 +59,10 @@ describe("OpenCode plugin installer", () => { const cwd = tmp(); const dir = join(cwd, ".opencode", "plugins"); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "interlinked.js"), `// ${OPENCODE_PLUGIN_MARKER}\n`); + writeFileSync(join(dir, "interlinked-opencode2.js"), `// ${OPENCODE_PLUGIN_MARKER}\n`); installOpencode2Hooks(cwd, ""); expect(existsSync(join(dir, "interlinked-opencode2.ts"))).toBe(true); - expect(existsSync(join(dir, "interlinked.js"))).toBe(false); + expect(existsSync(join(dir, "interlinked-opencode2.js"))).toBe(false); }); it("P4: does not overwrite a v1 managed interlinked.ts", () => { diff --git a/src/lib/hook-installers-opencode.ts b/src/lib/hook-installers-opencode.ts index 1ff9c5af..5f61d3d4 100644 --- a/src/lib/hook-installers-opencode.ts +++ b/src/lib/hook-installers-opencode.ts @@ -7,8 +7,8 @@ // installer writes `interlinked-opencode2.ts` so both can be enabled. import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { opencodeUserPluginDir } from "./opencode-runtime.js"; import { buildOpencodePluginSource, OPENCODE_PLUGIN_FILENAME, @@ -30,8 +30,7 @@ export function opencodePluginRelPath(): string { export function getOpencodePluginPath(cwd: string, scope: "user" | "project" | "local" = "project"): string { if (scope === "user") { - const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); - return join(home, ".config", "opencode", "plugins", OPENCODE_PLUGIN_FILENAME); + return join(opencodeUserPluginDir(), OPENCODE_PLUGIN_FILENAME); } return join(cwd, ".opencode", "plugins", OPENCODE_PLUGIN_FILENAME); } @@ -43,20 +42,9 @@ function writePlugin(path: string): void { } function pluginPaths(cwd: string): string[] { - const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir(); const project = getOpencodePluginPath(cwd, "project"); - const userV2 = getOpencodePluginPath(cwd, "user"); const projectDir = dirname(project); - const v1UserDir = join(home, ".config", "opencode", "plugins"); - return [ - project, - userV2, - join(projectDir, "interlinked.js"), - join(projectDir, "interlinked.ts"), - join(v1UserDir, "interlinked.js"), - join(v1UserDir, "interlinked-opencode2.js"), - join(v1UserDir, "interlinked.ts"), - ]; + return [project, join(projectDir, "interlinked-opencode2.js")]; } function pluginIsOurs(path: string): boolean { @@ -74,11 +62,9 @@ function pluginIsOurs(path: string): boolean { */ export function installOpencode2Hooks(cwd: string, _hookScriptPath: string): void { const project = getOpencodePluginPath(cwd, "project"); - const userPlugin = getOpencodePluginPath(cwd, "user"); writePlugin(project); - writePlugin(userPlugin); for (const leftover of pluginPaths(cwd)) { - if (leftover === project || leftover === userPlugin) continue; + if (leftover === project) continue; if (!pluginIsOurs(leftover)) continue; rmSync(leftover, { force: true }); } diff --git a/src/lib/opencode-plugin-runtime.test.ts b/src/lib/opencode-plugin-runtime.test.ts new file mode 100644 index 00000000..a520bf9c --- /dev/null +++ b/src/lib/opencode-plugin-runtime.test.ts @@ -0,0 +1,139 @@ +import { createServer, type Server } from "node:net"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildOpencodePluginSource } from "./opencode-plugin-source.js"; +import { mapOpencode2Tool } from "./opencode-tool-map.js"; + +interface PluginModule { + default: { id: string; setup: (ctx: unknown) => Promise }; +} + +async function loadPlugin(dir: string): Promise { + const path = join(dir, "plugin.ts"); + writeFileSync(path, buildOpencodePluginSource()); + return (await import(pathToFileURL(path).href)) as PluginModule; +} + +function startHarness(dir: string, replies: string[]): Promise<{ server: Server; sock: string }> { + mkdirSync(join(dir, ".interlinked"), { recursive: true }); + const sock = join(dir, ".interlinked", "harness.sock"); + return new Promise((resolve, reject) => { + const server = createServer((c) => { + let buf = ""; + c.on("data", (chunk) => { + buf += chunk.toString("utf8"); + if (!buf.includes("\n")) return; + const reply = replies.shift() ?? '{"decision":"allow"}\n'; + c.write(reply); + }); + }); + server.on("error", reject); + server.listen(sock, () => resolve({ server, sock })); + }); +} + +describe("generated OpenCode v2 plugin against a fake host", () => { + const dirs: string[] = []; + afterEach(async () => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + it("registers tool hooks and event.subscribe lifecycle, blocks Write, skips PostToolUse on read", async () => { + const dir = mkdtempSync(join(tmpdir(), "oc2-plugin-")); + dirs.push(dir); + const { server } = await startHarness(dir, [ + '{"decision":"block","reason":"nope"}\n', + '{"decision":"allow","warnings":["[interlinked:x] warn"]}\n', + ]); + try { + const hooks: Record) => Promise> = {}; + const events: Record) => unknown> = {}; + const plugin = await loadPlugin(dir); + expect(plugin.default.id).toBe("interlinked-opencode2"); + await plugin.default.setup({ + location: { directory: dir }, + tool: { + hook: async (name: string, fn: (e: Record) => Promise) => { + hooks[name] = fn; + }, + }, + event: { + subscribe: (name: string, fn: (e: Record) => unknown) => { + events[name] = fn; + }, + }, + }); + expect(Object.keys(hooks).sort()).toEqual(["execute.after", "execute.before"]); + expect(Object.keys(events).sort()).toEqual(["session.created", "session.deleted", "session.idle"]); + await expect( + hooks["execute.before"]?.({ + tool: "write", + args: { filePath: join(dir, "a.ts"), content: "x" }, + sessionID: "s", + }), + ).rejects.toThrow(/nope/); + const afterRead = { tool: "read", args: { filePath: "a.ts" }, sessionID: "s" }; + await hooks["execute.after"]?.(afterRead); + expect(afterRead).not.toHaveProperty("output"); + const afterWrite: Record = { + tool: "write", + args: { filePath: "a.ts", content: "x" }, + sessionID: "s", + output: "ok", + }; + await hooks["execute.after"]?.(afterWrite); + expect(String(afterWrite.output)).toContain("[interlinked:x] warn"); + events["session.created"]?.({ id: "s" }); + events["session.deleted"]?.({ id: "s" }); + events["session.idle"]?.({ id: "s" }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("cold-blocks rm -fr / on malformed daemon JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "oc2-plugin-")); + dirs.push(dir); + const { server } = await startHarness(dir, ["not-json\n"]); + try { + const hooks: Record) => Promise> = {}; + const plugin = await loadPlugin(dir); + await plugin.default.setup({ + location: { directory: dir }, + tool: { + hook: async (name: string, fn: (e: Record) => Promise) => { + hooks[name] = fn; + }, + }, + event: { subscribe: () => undefined }, + }); + await expect( + hooks["execute.before"]?.({ tool: "bash", args: { command: "rm -fr /" }, sessionID: "s" }), + ).rejects.toThrow(/BLOCKED/); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("generated mapTool matches mapOpencode2Tool", () => { + const src = buildOpencodePluginSource(); + const start = src.indexOf("function asRecord"); + const end = src.indexOf("function findSocket"); + const mapTool = new Function(`${src.slice(start, end)}; return mapTool;`)() as ( + tool: string, + args: unknown, + ) => { tool_name: string; tool_input: Record }; + const samples: Array<[string, Record]> = [ + ["write", { filePath: "/a.ts", content: "x" }], + ["edit", { filePath: "/a.ts", oldString: "a", newString: "b" }], + ["bash", { command: "ls", workdir: "/tmp" }], + ]; + for (const [tool, args] of samples) { + expect(mapTool(tool, args)).toEqual(mapOpencode2Tool(tool, args)); + } + }); +}); diff --git a/src/lib/opencode-plugin-source.ts b/src/lib/opencode-plugin-source.ts index 9fe8bcaf..4ac2f77d 100644 --- a/src/lib/opencode-plugin-source.ts +++ b/src/lib/opencode-plugin-source.ts @@ -30,13 +30,8 @@ const PLUGIN_ID = ${JSON.stringify(OPENCODE_PLUGIN_ID)}; function isOpenCodeV2() { const env = typeof process === "undefined" ? {} : process.env; - const argv = typeof process === "undefined" ? [] : process.argv; - if (Boolean(env.OPENCODE2) || env.INTERLINKED_CLIENT === "opencode2") return true; - if (String(env.XDG_CONFIG_HOME || "").includes("opencode-v2")) return true; - if (argv.some((part) => String(part).includes("opencode2"))) return true; - // This file is the v2 plugin. Gate unless the process is clearly OpenCode v1. + if (env.INTERLINKED_CLIENT === "opencode2" || Boolean(env.OPENCODE2)) return true; if (env.INTERLINKED_CLIENT === "opencode") return false; - if (env.OPENCODE && !env.OPENCODE2) return false; return true; } @@ -135,12 +130,14 @@ function coldBlock(toolName, toolInput) { return null; } +function unavailable(event) { + const reason = coldBlock(event.tool_name, event.tool_input || {}); + return reason ? { decision: "block", reason } : { decision: "allow" }; +} + function queryHarness(event) { const socketPath = findSocket(event.cwd); - if (!socketPath) { - const reason = coldBlock(event.tool_name, event.tool_input || {}); - return Promise.resolve(reason ? { decision: "block", reason } : { decision: "allow" }); - } + if (!socketPath) return Promise.resolve(unavailable(event)); return new Promise((resolve) => { let settled = false; const finish = (value) => { @@ -150,10 +147,7 @@ function queryHarness(event) { try { socket.destroy(); } catch { /* ignore */ } resolve(value); }; - const timer = setTimeout(() => { - const reason = coldBlock(event.tool_name, event.tool_input || {}); - finish(reason ? { decision: "block", reason } : { decision: "allow" }); - }, TIMEOUT_MS); + const timer = setTimeout(() => finish(unavailable(event)), TIMEOUT_MS); const socket = createConnection(socketPath); let buf = ""; socket.on("connect", () => socket.write(JSON.stringify(event) + "\\n")); @@ -162,17 +156,15 @@ function queryHarness(event) { const nl = buf.indexOf("\\n"); if (nl === -1) return; try { - finish(JSON.parse(buf.slice(0, nl))); + const parsed = JSON.parse(buf.slice(0, nl)); + finish(parsed && typeof parsed === "object" ? parsed : unavailable(event)); } catch { - finish({ decision: "allow" }); + finish(unavailable(event)); } }); - socket.on("error", () => { - const reason = coldBlock(event.tool_name, event.tool_input || {}); - finish(reason ? { decision: "block", reason } : { decision: "allow" }); - }); + socket.on("error", () => finish(unavailable(event))); socket.on("close", () => { - if (!settled) finish({ decision: "allow" }); + if (!settled) finish(unavailable(event)); }); }); } @@ -224,11 +216,26 @@ async function gateBefore(event, pluginCwd) { } } +const MUTATING = { write: true, edit: true, bash: true, shell: true, apply_patch: true, Write: true, Edit: true, Bash: true }; + +function applyFindings(event, decision) { + if (!decision) return; + const extra = [decision.reason, ...(decision.warnings || [])].filter(Boolean).join("\\n"); + if (!extra) return; + if (event && typeof event.append === "function") { + event.append(extra); + return; + } + if (event && typeof event.output === "string") event.output = event.output + "\\n" + extra; +} + async function gateAfter(event, pluginCwd) { const tool = (event && (event.tool || event.name || event.id)) || "unknown"; - const mapped = mapTool(typeof tool === "string" ? tool : String(tool), readArgs(event)); + const toolKey = typeof tool === "string" ? tool : String(tool); + if (!MUTATING[toolKey] && !MUTATING[toolKey.toLowerCase()]) return; + const mapped = mapTool(toolKey, readArgs(event)); const sessionId = event && (event.sessionID || event.sessionId); - await queryHarness({ + const decision = await queryHarness({ ...baseEvent(pluginCwd, sessionId), hook_event: "PostToolUse", tool_name: mapped.tool_name, @@ -236,6 +243,7 @@ async function gateAfter(event, pluginCwd) { tool_response: event && (event.output || event.result), tool_use_id: event && event.callID, }); + applyFindings(event, decision); } let armed = false; @@ -287,16 +295,17 @@ export default { return gateBefore({ tool: "bash", args: { command }, sessionID: event && event.sessionID }, pluginCwd); }); } - if (ctx.session && typeof ctx.session.hook === "function") { - await ctx.session.hook("created", (event) => - queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "SessionStart" }), - ); - await ctx.session.hook("deleted", (event) => - queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "SessionEnd" }), - ); - await ctx.session.hook("idle", (event) => - queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID)), hook_event: "Stop" }), - ); + if (ctx.event && typeof ctx.event.subscribe === "function") { + const life = [ + ["session.created", "SessionStart"], + ["session.deleted", "SessionEnd"], + ["session.idle", "Stop"], + ]; + for (const [name, hookEvent] of life) { + ctx.event.subscribe(name, (event) => + queryHarness({ ...baseEvent(pluginCwd, event && (event.id || event.sessionID || event.sessionId)), hook_event: hookEvent }), + ); + } } }, }; diff --git a/src/lib/opencode-runtime.test.ts b/src/lib/opencode-runtime.test.ts new file mode 100644 index 00000000..febe2ef5 --- /dev/null +++ b/src/lib/opencode-runtime.test.ts @@ -0,0 +1,56 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { detectClients } from "./settings.js"; +import { isOpenCodeV2Env, opencodeUserPluginDir, opencodeUserPluginRelPath } from "./opencode-runtime.js"; + +describe("isOpenCodeV2Env", () => { + it("P1: OPENCODE2 or INTERLINKED_CLIENT=opencode2", () => { + expect(isOpenCodeV2Env({ OPENCODE2: "1" })).toBe(true); + expect(isOpenCodeV2Env({ INTERLINKED_CLIENT: "opencode2" })).toBe(true); + }); + it("N1: argv/XDG substrings and v1 client are not v2", () => { + expect(isOpenCodeV2Env({ XDG_CONFIG_HOME: "/tmp/opencode-v2" })).toBe(false); + expect(isOpenCodeV2Env({ INTERLINKED_CLIENT: "opencode" })).toBe(false); + expect(isOpenCodeV2Env({ OPENCODE: "1" })).toBe(false); + }); +}); + +describe("opencode user plugin path", () => { + it("honors XDG_CONFIG_HOME", () => { + expect(opencodeUserPluginDir({ XDG_CONFIG_HOME: "/xdg" })).toBe("/xdg/opencode/plugins"); + expect(opencodeUserPluginRelPath("interlinked-opencode2.ts", { XDG_CONFIG_HOME: "/xdg" })).toBe( + "/xdg/opencode/plugins/interlinked-opencode2.ts", + ); + }); + it("defaults to ~/.config/opencode/plugins", () => { + expect(opencodeUserPluginRelPath("interlinked-opencode2.ts", { HOME: "/home/u" })).toBe( + "~/.config/opencode/plugins/interlinked-opencode2.ts", + ); + }); +}); + +describe("detectClients OpenCode mutual exclusion", () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + it("directory .opencode detects v1 only", () => { + const cwd = mkdtempSync(join(tmpdir(), "oc-detect-")); + dirs.push(cwd); + mkdirSync(join(cwd, ".opencode")); + const names = detectClients(cwd, {}).filter((c) => c.exists).map((c) => c.name); + expect(names).toContain("opencode"); + expect(names).not.toContain("opencode2"); + }); + it("OPENCODE2 env detects v2 and not v1 from the shared directory", () => { + const cwd = mkdtempSync(join(tmpdir(), "oc-detect-")); + dirs.push(cwd); + mkdirSync(join(cwd, ".opencode")); + const names = detectClients(cwd, { OPENCODE2: "1" }).filter((c) => c.exists).map((c) => c.name); + expect(names).toContain("opencode2"); + expect(names).not.toContain("opencode"); + }); +}); diff --git a/src/lib/opencode-runtime.ts b/src/lib/opencode-runtime.ts index b170d3e8..a2590d91 100644 --- a/src/lib/opencode-runtime.ts +++ b/src/lib/opencode-runtime.ts @@ -1,10 +1,26 @@ -// Shared OpenCode v1 vs v2 process detection. Both adapters and (as a -// copied snippet) the generated plugins use the same rules so v1 never -// claims an opencode2 process and the two plugins never double-gate. +// Shared OpenCode v1 vs v2 process detection and user plugin path. +// Identity is provider-owned env only: OPENCODE2 or INTERLINKED_CLIENT. +// Argv / XDG substrings are not a security boundary. + +import { homedir } from "node:os"; +import { join } from "node:path"; export function isOpenCodeV2Env(env: NodeJS.ProcessEnv = process.env): boolean { - if (env.OPENCODE2) return true; if (env.INTERLINKED_CLIENT === "opencode2") return true; - const xdg = env.XDG_CONFIG_HOME ?? ""; - return xdg.includes("opencode-v2"); + if (env.INTERLINKED_CLIENT === "opencode") return false; + return Boolean(env.OPENCODE2); +} + +/** User-scope OpenCode plugin directory: XDG_CONFIG_HOME/opencode/plugins, else ~/.config/opencode/plugins. */ +export function opencodeUserPluginDir(env: NodeJS.ProcessEnv = process.env): string { + const xdg = env.XDG_CONFIG_HOME; + if (typeof xdg === "string" && xdg.length > 0) return join(xdg, "opencode", "plugins"); + const home = env.HOME ?? env.USERPROFILE ?? homedir(); + return join(home, ".config", "opencode", "plugins"); +} + +export function opencodeUserPluginRelPath(filename: string, env: NodeJS.ProcessEnv = process.env): string { + const xdg = env.XDG_CONFIG_HOME; + if (typeof xdg === "string" && xdg.length > 0) return join(xdg, "opencode", "plugins", filename); + return `~/.config/opencode/plugins/${filename}`; } diff --git a/src/lib/opencode-tool-map.ts b/src/lib/opencode-tool-map.ts index c3b2024a..d807bb9b 100644 --- a/src/lib/opencode-tool-map.ts +++ b/src/lib/opencode-tool-map.ts @@ -128,5 +128,3 @@ export function opencode2ColdBlockReason(toolName: string, toolInput: JsonObject } return null; } - - diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 2742a5ee..830cce25 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -138,14 +138,18 @@ export function detectClients( cwd: string, env: NodeJS.ProcessEnv = process.env, ): DetectedClient[] { - return CLIENT_CONFIGS.map((config) => ({ - name: config.name, - settingsPath: getClientSettingsPath(cwd, config), - exists: + const v2Env = env.OPENCODE2 || env.INTERLINKED_CLIENT === "opencode2"; + return CLIENT_CONFIGS.map((config) => { + const fromEnv = config.detectFromEnv?.(env) === true; + const fromFiles = existsSync(join(cwd, config.configDir)) || - config.detectionFiles?.some((path) => existsSync(join(cwd, path))) === true || - config.detectFromEnv?.(env) === true, - })); + config.detectionFiles?.some((path) => existsSync(join(cwd, path))) === true; + let exists = fromEnv || fromFiles; + // `.opencode/` is shared. Directory presence is v1; v2 requires an explicit env/client signal. + if (config.name === "opencode2") exists = fromEnv; + if (config.name === "opencode" && v2Env) exists = fromEnv; + return { name: config.name, settingsPath: getClientSettingsPath(cwd, config), exists }; + }); } /** Maps a legacy `ClientName` id to the adapter `RunnerId` vocabulary. The two diff --git a/src/lib/viz/agent-roster.test.ts b/src/lib/viz/agent-roster.test.ts index 68ef1b90..62998fac 100644 --- a/src/lib/viz/agent-roster.test.ts +++ b/src/lib/viz/agent-roster.test.ts @@ -23,6 +23,7 @@ describe("runnerOf", () => { expect(runnerOf("session-codex-abc")).toBe("codex"); expect(runnerOf("session-gemini-abc")).toBe("gemini"); expect(runnerOf("session-opencode-abc")).toBe("opencode"); + expect(runnerOf("session-opencode2-abc")).toBe("opencode2"); expect(runnerOf("session-pi-abc")).toBe("pi"); }); diff --git a/src/lib/viz/agent-roster.ts b/src/lib/viz/agent-roster.ts index a04db9e3..692e5f1d 100644 --- a/src/lib/viz/agent-roster.ts +++ b/src/lib/viz/agent-roster.ts @@ -69,7 +69,7 @@ export function hueForAgent(id: string): number { /** Extract the runner family from an agent name, or "unknown". */ export function runnerOf(agent: string): string { const lower = agent.toLowerCase(); - return RUNNERS.find((r) => lower.includes(r)) ?? "unknown"; + return [...RUNNERS].sort((a, b) => b.length - a.length).find((r) => lower.includes(r)) ?? "unknown"; } /** Compact lane label: runner plus the identifying tail of the agent name. */ From 7ad4949aad179873e5880bd2ca88ad8950cac438 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:34:33 +0000 Subject: [PATCH 6/8] test: pin every provider against write-gate and AgentSource maps A new adapter must ship a native Write fixture that isFileWrite accepts, an exhaustive AgentSource provider map, blank-env non-detection, and a project-relative managed plugin path. Shared .opencode/ must not detect both OpenCode clients. Register lowercase write/edit spellings in the write-tool table so OpenCode/Cursor casing cannot skip the file-write list. --- .../adapters/provider-invariants.test.ts | 140 ++++++++++++++++++ src/harness/evaluator/tool-classifiers.ts | 3 + src/harness/server/agent-event-capture.ts | 2 +- src/harness/server/collection-writer.ts | 5 +- src/harness/types/events.ts | 20 +-- src/lib/write-tool-registry.test.ts | 2 +- src/lib/write-tool-registry.ts | 2 + 7 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 src/harness/adapters/provider-invariants.test.ts diff --git a/src/harness/adapters/provider-invariants.test.ts b/src/harness/adapters/provider-invariants.test.ts new file mode 100644 index 00000000..d137b790 --- /dev/null +++ b/src/harness/adapters/provider-invariants.test.ts @@ -0,0 +1,140 @@ +// Cross-provider invariants. A new runner that only has a smoke test is not done: +// these must stay green for every adapter in buildAllAdapters(). + +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { isFileWrite } from "../evaluator/tool-classifiers.js"; +import { PROVIDER_BY_SOURCE } from "../server/agent-event-capture.js"; +import { AGENT_SOURCES } from "../types/events.js"; +import { DIRECT_FILE_EDIT_TOOLS } from "../../lib/write-tool-registry.js"; +import { CLIENT_TO_RUNNER, detectClients } from "../../lib/settings.js"; +import { buildAllAdapters } from "./index.js"; +import type { RunnerId } from "../unified-event.js"; + +interface WriteFixture { + nativeEvent: string; + payload: Record; +} + +/** One native write/edit payload per runner. Missing an adapter id fails the suite. */ +const WRITE_FIXTURES: Record = { + "claude-code": { + nativeEvent: "PreToolUse", + payload: { session_id: "s", cwd: "/r", tool_name: "Write", tool_input: { file_path: "/r/a.ts", content: "x" } }, + }, + "copilot-cli": { + nativeEvent: "preToolUse", + payload: { sessionId: "s", cwd: "/r", toolName: "edit_file", toolInput: { path: "/r/a.ts" } }, + }, + cursor: { + nativeEvent: "preToolUse", + payload: { session_id: "s", cwd: "/r", tool_name: "Edit", tool_input: { file_path: "/r/a.ts" } }, + }, + "gemini-cli": { + nativeEvent: "BeforeTool", + payload: { session_id: "s", cwd: "/r", tool_name: "write_file", tool_input: { path: "/r/a.ts" } }, + }, + codex: { + nativeEvent: "PreToolUse", + payload: { session_id: "s", cwd: "/r", tool_name: "Write", tool_input: { file_path: "/r/a.ts", content: "x" } }, + }, + opencode: { + nativeEvent: "tool.execute.before", + payload: { sessionID: "s", cwd: "/r", tool: "edit", args: { file_path: "/r/a.ts" } }, + }, + opencode2: { + nativeEvent: "tool.execute.before", + payload: { sessionID: "s", cwd: "/r", tool: "write", args: { filePath: "/r/a.ts", content: "x" } }, + }, + pi: { + nativeEvent: "tool_call", + payload: { sessionId: "s", cwd: "/r", toolName: "edit", input: { file_path: "/r/a.ts" } }, + }, +}; + +describe("agent_source maps are exhaustive", () => { + it("PROVIDER_BY_SOURCE has a label for every AgentSource", () => { + for (const source of AGENT_SOURCES) { + expect(PROVIDER_BY_SOURCE[source], source).toEqual(expect.any(String)); + expect(PROVIDER_BY_SOURCE[source].length).toBeGreaterThan(0); + } + expect(Object.keys(PROVIDER_BY_SOURCE).sort()).toEqual([...AGENT_SOURCES].sort()); + }); + + it("CLIENT_TO_RUNNER covers every client name and a live adapter", () => { + const adapterIds = new Set(buildAllAdapters().map((a) => a.id)); + for (const runner of Object.values(CLIENT_TO_RUNNER)) { + expect(adapterIds.has(runner), runner).toBe(true); + } + }); +}); + +describe("every adapter's native write is a harness file-write", () => { + it("WRITE_FIXTURES lists every adapter exactly once", () => { + expect(Object.keys(WRITE_FIXTURES).sort()).toEqual( + buildAllAdapters().map((a) => a.id).sort(), + ); + }); + + it("parsed write tool names are isFileWrite / direct edits (or file_operation)", () => { + for (const adapter of buildAllAdapters()) { + const fixture = WRITE_FIXTURES[adapter.id]; + const event = adapter.parseHookInput(fixture.payload, fixture.nativeEvent); + if (event.action.kind === "file_operation") { + expect(["create", "edit", "write", "delete"]).toContain(event.action.operation); + continue; + } + expect(event.action.kind, adapter.id).toBe("tool_call"); + if (event.action.kind !== "tool_call") continue; + const name = event.action.tool_name; + expect( + isFileWrite(name) || DIRECT_FILE_EDIT_TOOLS.includes(name), + `${adapter.id} emitted ${name}, which is not a file write`, + ).toBe(true); + } + }); + + it("isFileWrite agrees with DIRECT_FILE_EDIT_TOOLS", () => { + for (const name of DIRECT_FILE_EDIT_TOOLS) { + expect(isFileWrite(name), name).toBe(true); + } + }); +}); + +describe("adapter detection", () => { + it("no adapter claims a blank environment", () => { + for (const adapter of buildAllAdapters()) { + expect(adapter.detectFromEnv({}), adapter.id).toBe(false); + } + }); +}); + +describe("detectClients shared config directories", () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + it("a shared project dir without env selects at most one OpenCode client", () => { + const cwd = mkdtempSync(join(tmpdir(), "prov-detect-")); + dirs.push(cwd); + mkdirSync(join(cwd, ".opencode")); + const names = detectClients(cwd, {}).filter((c) => c.exists).map((c) => c.name); + const open = names.filter((n) => n === "opencode" || n === "opencode2"); + expect(open).toEqual(["opencode"]); + }); +}); + +describe("managed plugin install paths stay in-repo for project scope", () => { + it("fileContent adapters use a project-relative path", () => { + for (const adapter of buildAllAdapters()) { + const frag = adapter.renderSettingsFragment("/bin/hook", "project"); + if (!frag.fileContent) continue; + expect(frag.path.startsWith("~/"), adapter.id).toBe(false); + expect(frag.path.startsWith("/"), adapter.id).toBe(false); + } + }); +}); diff --git a/src/harness/evaluator/tool-classifiers.ts b/src/harness/evaluator/tool-classifiers.ts index ef8ba721..3866e460 100644 --- a/src/harness/evaluator/tool-classifiers.ts +++ b/src/harness/evaluator/tool-classifiers.ts @@ -60,11 +60,14 @@ export function isFileWrite(toolName: string | undefined): boolean { return [ "Write", "Edit", + "Update", "MultiEdit", "WriteFile", "EditFile", "write_file", "edit_file", + "write", + "edit", "multi_edit", "FileWrite", "FileEdit", diff --git a/src/harness/server/agent-event-capture.ts b/src/harness/server/agent-event-capture.ts index 38911647..ce6d6593 100644 --- a/src/harness/server/agent-event-capture.ts +++ b/src/harness/server/agent-event-capture.ts @@ -66,7 +66,7 @@ export const AGENT_TRANSCRIPT_REDRAIN_MS = 750; /** Provider label per agent source — same vocabulary the tool_event records * use (`detectProvider` in lib/collection/builder.ts). */ -const PROVIDER_BY_SOURCE: Record = { +export const PROVIDER_BY_SOURCE: Record = { claude: "claude-code", gemini: "gemini-cli", copilot: "copilot", diff --git a/src/harness/server/collection-writer.ts b/src/harness/server/collection-writer.ts index da03b885..6417a7a4 100644 --- a/src/harness/server/collection-writer.ts +++ b/src/harness/server/collection-writer.ts @@ -16,11 +16,14 @@ import type { JsonObject } from "../../lib/json-types.js"; import { eventAttributionFields } from "../event-attribution-fields.js"; import type { AgentSource, HarnessEvent } from "../types.js"; -const CLIENT_RUNNER_BY_AGENT_SOURCE: Partial> = { +const CLIENT_RUNNER_BY_AGENT_SOURCE: Record = { + claude: "claude-code", codex: "codex", copilot: "copilot", gemini: "gemini-cli", + cursor: "cursor", opencode: "opencode", + opencode2: "opencode2", pi: "pi", }; diff --git a/src/harness/types/events.ts b/src/harness/types/events.ts index 4493baf1..318401fb 100644 --- a/src/harness/types/events.ts +++ b/src/harness/types/events.ts @@ -56,15 +56,17 @@ export type HookEventName = // Generic | string; -export type AgentSource = - | "claude" - | "copilot" - | "codex" - | "gemini" - | "cursor" - | "opencode" - | "opencode2" - | "pi"; +export const AGENT_SOURCES = [ + "claude", + "copilot", + "codex", + "gemini", + "cursor", + "opencode", + "opencode2", + "pi", +] as const; +export type AgentSource = (typeof AGENT_SOURCES)[number]; /** * Whether a given agent runtime can surface an interactive permission prompt diff --git a/src/lib/write-tool-registry.test.ts b/src/lib/write-tool-registry.test.ts index f48141e5..fe5feeee 100644 --- a/src/lib/write-tool-registry.test.ts +++ b/src/lib/write-tool-registry.test.ts @@ -36,7 +36,7 @@ describe("write-tool-registry — positive (must classify as a writer)", () => { }); it("P3: the other-runner patch verbs are direct edits too", () => { - for (const name of ["apply_patch", "str_replace", "create", "write_file", "edit_file"]) { + for (const name of ["apply_patch", "str_replace", "create", "write_file", "edit_file", "write", "edit"]) { expect(isDirectFileEditTool(name)).toBe(true); } }); diff --git a/src/lib/write-tool-registry.ts b/src/lib/write-tool-registry.ts index dc4fc226..e0264460 100644 --- a/src/lib/write-tool-registry.ts +++ b/src/lib/write-tool-registry.ts @@ -83,6 +83,8 @@ export const WRITE_TOOLS: readonly WriteToolEntry[] = [ { name: "EditFile", channel: "direct", claudeCodeNative: false }, { name: "write_file", channel: "direct", claudeCodeNative: false }, { name: "edit_file", channel: "direct", claudeCodeNative: false }, + { name: "write", channel: "direct", claudeCodeNative: false }, + { name: "edit", channel: "direct", claudeCodeNative: false }, // Copilot CLI / Codex patch verbs. { name: "apply_patch", channel: "direct", claudeCodeNative: false, codexNative: true }, { name: "str_replace", channel: "direct", claudeCodeNative: false }, From edcbc676ed95174a879de35e20beb261b2c8a2f1 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:57:02 +0000 Subject: [PATCH 7/8] test: cover ask-collapse, install scope, docs paths, and socket EOF Every adapter must: not treat ask as allow when it has no prompt primitive; keep project-scope managed plugins out of \$HOME; appear in architecture.md by hook path; map AgentSource tokens in the roster; and list Write-fixture keys in CONSUMED_PAYLOAD_KEYS. OpenCode v2 additionally cold-blocks when the daemon closes before a complete response line. --- .../adapters/provider-invariants.test.ts | 105 +++++++++++++++++- src/harness/payload-key-census.ts | 1 + src/lib/opencode-plugin-runtime.test.ts | 32 ++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/harness/adapters/provider-invariants.test.ts b/src/harness/adapters/provider-invariants.test.ts index d137b790..7059e576 100644 --- a/src/harness/adapters/provider-invariants.test.ts +++ b/src/harness/adapters/provider-invariants.test.ts @@ -1,21 +1,26 @@ // Cross-provider invariants. A new runner that only has a smoke test is not done: // these must stay green for every adapter in buildAllAdapters(). -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { CONSUMED_PAYLOAD_KEYS } from "../payload-key-census.js"; import { isFileWrite } from "../evaluator/tool-classifiers.js"; +import { installHooks } from "../installer.js"; import { PROVIDER_BY_SOURCE } from "../server/agent-event-capture.js"; import { AGENT_SOURCES } from "../types/events.js"; import { DIRECT_FILE_EDIT_TOOLS } from "../../lib/write-tool-registry.js"; import { CLIENT_TO_RUNNER, detectClients } from "../../lib/settings.js"; +import { runnerOf } from "../../lib/viz/agent-roster.js"; import { buildAllAdapters } from "./index.js"; import type { RunnerId } from "../unified-event.js"; interface WriteFixture { nativeEvent: string; payload: Record; + /** Expected unified tool_class after parse. */ + toolClass: "modify" | "write"; } /** One native write/edit payload per runner. Missing an adapter id fails the suite. */ @@ -23,37 +28,62 @@ const WRITE_FIXTURES: Record = { "claude-code": { nativeEvent: "PreToolUse", payload: { session_id: "s", cwd: "/r", tool_name: "Write", tool_input: { file_path: "/r/a.ts", content: "x" } }, + toolClass: "write", }, "copilot-cli": { nativeEvent: "preToolUse", payload: { sessionId: "s", cwd: "/r", toolName: "edit_file", toolInput: { path: "/r/a.ts" } }, + toolClass: "modify", }, cursor: { nativeEvent: "preToolUse", payload: { session_id: "s", cwd: "/r", tool_name: "Edit", tool_input: { file_path: "/r/a.ts" } }, + toolClass: "modify", }, "gemini-cli": { nativeEvent: "BeforeTool", payload: { session_id: "s", cwd: "/r", tool_name: "write_file", tool_input: { path: "/r/a.ts" } }, + toolClass: "write", }, codex: { nativeEvent: "PreToolUse", payload: { session_id: "s", cwd: "/r", tool_name: "Write", tool_input: { file_path: "/r/a.ts", content: "x" } }, + toolClass: "write", }, opencode: { nativeEvent: "tool.execute.before", payload: { sessionID: "s", cwd: "/r", tool: "edit", args: { file_path: "/r/a.ts" } }, + toolClass: "modify", }, opencode2: { nativeEvent: "tool.execute.before", payload: { sessionID: "s", cwd: "/r", tool: "write", args: { filePath: "/r/a.ts", content: "x" } }, + toolClass: "write", }, pi: { nativeEvent: "tool_call", payload: { sessionId: "s", cwd: "/r", toolName: "edit", input: { file_path: "/r/a.ts" } }, + toolClass: "modify", }, }; +function looksLikeAllow(out: { exit_code: number; stdout?: string }): boolean { + if (out.exit_code !== 0) return false; + if (!out.stdout) return true; + try { + const parsed = JSON.parse(out.stdout) as { decision?: string; permissionDecision?: string }; + return parsed.decision === "allow" || parsed.permissionDecision === "allow"; + } catch { + return false; + } +} + +function adapterHasAskOrPermission(adapter: ReturnType[number]): boolean { + return adapter.capabilities.events.some( + (event) => event.install && (event.control === "ask" || event.control === "permission"), + ); +} + describe("agent_source maps are exhaustive", () => { it("PROVIDER_BY_SOURCE has a label for every AgentSource", () => { for (const source of AGENT_SOURCES) { @@ -93,6 +123,7 @@ describe("every adapter's native write is a harness file-write", () => { isFileWrite(name) || DIRECT_FILE_EDIT_TOOLS.includes(name), `${adapter.id} emitted ${name}, which is not a file write`, ).toBe(true); + expect(["modify", "write"]).toContain(event.action.tool_class); } }); @@ -137,4 +168,76 @@ describe("managed plugin install paths stay in-repo for project scope", () => { expect(frag.path.startsWith("/"), adapter.id).toBe(false); } }); + + it("project-scope install does not write the user-scope plugin file", () => { + const cwd = mkdtempSync(join(tmpdir(), "prov-install-")); + const home = mkdtempSync(join(tmpdir(), "prov-home-")); + const prevHome = process.env.HOME; + const prevUser = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + delete process.env.XDG_CONFIG_HOME; + try { + for (const adapter of buildAllAdapters()) { + const projectFrag = adapter.renderSettingsFragment("/bin/hook", "project"); + if (!projectFrag.fileContent) continue; + const result = installHooks({ + cwd, + binaryPath: "/bin/hook", + runners: [adapter.id], + scope: "project", + }); + expect(result.ok, adapter.id).toBe(true); + expect(existsSync(join(cwd, projectFrag.path)), adapter.id).toBe(true); + const userFrag = adapter.renderSettingsFragment("/bin/hook", "user"); + const userRel = userFrag.path.replace(/^~\//, ""); + expect(existsSync(join(home, userRel)), `${adapter.id} wrote $HOME`).toBe(false); + } + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + if (prevUser === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUser; + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("ask without a native prompt is not an allow", () => { + it("encodeDecision(ask) is not indistinguishable from allow when the runner cannot ask", () => { + for (const adapter of buildAllAdapters()) { + if (adapterHasAskOrPermission(adapter)) continue; + const fixture = WRITE_FIXTURES[adapter.id]; + const event = adapter.parseHookInput(fixture.payload, fixture.nativeEvent); + const out = adapter.encodeDecision({ decision: "ask", reason: "confirm" }, event); + expect(looksLikeAllow(out), `${adapter.id} turned ask into allow`).toBe(false); + } + }); +}); + +describe("docs, roster, and payload census", () => { + it("architecture.md names every adapter's project hook path", () => { + const docs = readFileSync(join(import.meta.dirname, "..", "..", "..", "docs", "architecture.md"), "utf8"); + for (const adapter of buildAllAdapters()) { + expect( + docs, + `${adapter.id} path ${adapter.capabilities.project_hook_path} missing from architecture.md`, + ).toContain(adapter.capabilities.project_hook_path); + } + }); + + it("runnerOf maps every AgentSource token in a session name", () => { + for (const source of AGENT_SOURCES) { + expect(runnerOf(`session-${source}-deadbeef`)).toBe(source); + } + }); + + it("WRITE_FIXTURE top-level keys are in CONSUMED_PAYLOAD_KEYS", () => { + for (const [id, fixture] of Object.entries(WRITE_FIXTURES)) { + for (const key of Object.keys(fixture.payload)) { + expect(CONSUMED_PAYLOAD_KEYS.has(key), `${id} payload key ${key}`).toBe(true); + } + } + }); }); diff --git a/src/harness/payload-key-census.ts b/src/harness/payload-key-census.ts index 49759306..fea3082e 100644 --- a/src/harness/payload-key-census.ts +++ b/src/harness/payload-key-census.ts @@ -97,6 +97,7 @@ export const CONSUMED_PAYLOAD_KEYS: ReadonlySet = new Set([ "callID", "tool", "args", + "input", "output", "filePath", "oldString", diff --git a/src/lib/opencode-plugin-runtime.test.ts b/src/lib/opencode-plugin-runtime.test.ts index a520bf9c..8a32e730 100644 --- a/src/lib/opencode-plugin-runtime.test.ts +++ b/src/lib/opencode-plugin-runtime.test.ts @@ -95,6 +95,38 @@ describe("generated OpenCode v2 plugin against a fake host", () => { } }); + it("cold-blocks rm -fr / when the daemon closes before a complete line", async () => { + const dir = mkdtempSync(join(tmpdir(), "oc2-plugin-")); + dirs.push(dir); + mkdirSync(join(dir, ".interlinked"), { recursive: true }); + const sock = join(dir, ".interlinked", "harness.sock"); + const server = createServer((c) => { + c.on("data", () => c.end()); + }); + await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(sock, () => resolve()); + }); + try { + const hooks: Record) => Promise> = {}; + const plugin = await loadPlugin(dir); + await plugin.default.setup({ + location: { directory: dir }, + tool: { + hook: async (name: string, fn: (e: Record) => Promise) => { + hooks[name] = fn; + }, + }, + event: { subscribe: () => undefined }, + }); + await expect( + hooks["execute.before"]?.({ tool: "bash", args: { command: "rm -fr /" }, sessionID: "s" }), + ).rejects.toThrow(/BLOCKED/); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("cold-blocks rm -fr / on malformed daemon JSON", async () => { const dir = mkdtempSync(join(tmpdir(), "oc2-plugin-")); dirs.push(dir); From 889249a9cce42c61675691306e9f09c2e7e4d77c Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:06:03 +0000 Subject: [PATCH 8/8] fix: restore collection-writer omit rules and pin opencode2 client lists Claude and Cursor still omit client_runner (Cursor uses cursor_version). detectClients / enable / disable tests include the opencode2 client id. --- src/commands/disable.mutation-kill-luna.test.ts | 1 + src/commands/enable.mutation-kill-w44.test.ts | 2 +- src/harness/server/collection-writer.ts | 5 +++-- src/lib/__tests__/settings.test.ts | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/disable.mutation-kill-luna.test.ts b/src/commands/disable.mutation-kill-luna.test.ts index 98eeabdc..839e92d1 100644 --- a/src/commands/disable.mutation-kill-luna.test.ts +++ b/src/commands/disable.mutation-kill-luna.test.ts @@ -36,6 +36,7 @@ const CLIENTS: ClientName[] = [ "codex", "cursor", "opencode", + "opencode2", "pi", ]; diff --git a/src/commands/enable.mutation-kill-w44.test.ts b/src/commands/enable.mutation-kill-w44.test.ts index a08fb3d3..bc4d23e6 100644 --- a/src/commands/enable.mutation-kill-w44.test.ts +++ b/src/commands/enable.mutation-kill-w44.test.ts @@ -361,7 +361,7 @@ describe("noteUndetectedClients", () => { await enableCommand({}); const out = logged(logSpy); - expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, pi"); + expect(out).toContain("Not detected: copilot, gemini, codex, cursor, opencode, opencode2, pi"); }); }); diff --git a/src/harness/server/collection-writer.ts b/src/harness/server/collection-writer.ts index 6417a7a4..5c23ae90 100644 --- a/src/harness/server/collection-writer.ts +++ b/src/harness/server/collection-writer.ts @@ -16,12 +16,13 @@ import type { JsonObject } from "../../lib/json-types.js"; import { eventAttributionFields } from "../event-attribution-fields.js"; import type { AgentSource, HarnessEvent } from "../types.js"; +/** Empty string = omit `client_runner` (Claude is default; Cursor uses `cursor_version`). */ const CLIENT_RUNNER_BY_AGENT_SOURCE: Record = { - claude: "claude-code", + claude: "", codex: "codex", copilot: "copilot", gemini: "gemini-cli", - cursor: "cursor", + cursor: "", opencode: "opencode", opencode2: "opencode2", pi: "pi", diff --git a/src/lib/__tests__/settings.test.ts b/src/lib/__tests__/settings.test.ts index 9aa5e91f..167d1fa7 100644 --- a/src/lib/__tests__/settings.test.ts +++ b/src/lib/__tests__/settings.test.ts @@ -24,6 +24,7 @@ describe("detectClients", () => { "codex", "cursor", "opencode", + "opencode2", "pi", ]); }); @@ -104,6 +105,7 @@ describe("detectClients", () => { it("maps the new public client ids to their normalized runner ids", () => { expect(CLIENT_TO_RUNNER.opencode).toBe("opencode"); + expect(CLIENT_TO_RUNNER.opencode2).toBe("opencode2"); expect(CLIENT_TO_RUNNER.pi).toBe("pi"); }); });