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/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 212c9ada..ccc4423c 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`, 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. - **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.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/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..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"); }); }); @@ -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..cf9cfae9 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", () => { @@ -142,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/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/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/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..bfbe58b0 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,20 @@ 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;', + ' return env.INTERLINKED_CLIENT === "opencode2" || Boolean(env.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..0a88673b --- /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/plugins/interlinked-opencode2.ts", + ); + }); +}); + +describe("OpenCode detectFromEnv", () => { + it("P1: detects OPENCODE2", () => { + expect(adapter.detectFromEnv({ OPENCODE2: "1" })).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); + }); + 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..f174c161 --- /dev/null +++ b/src/harness/adapters/opencode2.ts @@ -0,0 +1,151 @@ +// =========================================== +// 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, 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 { + type ClassifierOverrides, + classifyFromToolName, +} 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, RunnerAdapter, SettingsFragment } from "./types.js"; + +const NATIVE_EVENTS = installedEventNames(OPENCODE2_CAPABILITIES); + +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", + experimental: true, + capabilities: OPENCODE2_CAPABILITIES, + 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, + 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" + ? opencodeUserPluginRelPath(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, + }; + }, + + + }; +} + +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/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", diff --git a/src/harness/adapters/provider-invariants.test.ts b/src/harness/adapters/provider-invariants.test.ts new file mode 100644 index 00000000..7059e576 --- /dev/null +++ b/src/harness/adapters/provider-invariants.test.ts @@ -0,0 +1,243 @@ +// 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 { 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. */ +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) { + 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); + expect(["modify", "write"]).toContain(event.action.tool_class); + } + }); + + 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); + } + }); + + 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/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/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/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 = new Set([ "trigger", "custom_instructions", "files_modified", + // OpenCode v2 plugin / adapter payload aliases + "sessionID", + "callID", + "tool", + "args", + "input", + "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..ce6d6593 100644 --- a/src/harness/server/agent-event-capture.ts +++ b/src/harness/server/agent-event-capture.ts @@ -66,13 +66,14 @@ 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", codex: "codex", cursor: "cursor", opencode: "opencode", + opencode2: "opencode2", pi: "pi", }; diff --git a/src/harness/server/collection-writer.ts b/src/harness/server/collection-writer.ts index da03b885..5c23ae90 100644 --- a/src/harness/server/collection-writer.ts +++ b/src/harness/server/collection-writer.ts @@ -16,11 +16,15 @@ 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> = { +/** Empty string = omit `client_runner` (Claude is default; Cursor uses `cursor_version`). */ +const CLIENT_RUNNER_BY_AGENT_SOURCE: Record = { + claude: "", codex: "codex", copilot: "copilot", gemini: "gemini-cli", + cursor: "", opencode: "opencode", + opencode2: "opencode2", pi: "pi", }; diff --git a/src/harness/types/events.ts b/src/harness/types/events.ts index 4aa0ff37..318401fb 100644 --- a/src/harness/types/events.ts +++ b/src/harness/types/events.ts @@ -56,14 +56,17 @@ export type HookEventName = // Generic | string; -export type AgentSource = - | "claude" - | "copilot" - | "codex" - | "gemini" - | "cursor" - | "opencode" - | "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/harness/unified-event.ts b/src/harness/unified-event.ts index 0fbe8faf..66425e39 100644 --- a/src/harness/unified-event.ts +++ b/src/harness/unified-event.ts @@ -17,6 +17,7 @@ export type RunnerId = | "gemini-cli" | "cursor" | "opencode" + | "opencode2" | "pi" | "unknown"; 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"); }); }); diff --git a/src/lib/collection/builder.ts b/src/lib/collection/builder.ts index 382f9e6b..0862a348 100644 --- a/src/lib/collection/builder.ts +++ b/src/lib/collection/builder.ts @@ -64,6 +64,7 @@ const DIRECT_PROVIDER_RUNNERS = new Set([ "gemini-cli", "cursor", "opencode", + "opencode2", "pi", ]); diff --git a/src/lib/hook-installers-opencode.test.ts b/src/lib/hook-installers-opencode.test.ts new file mode 100644 index 00000000..a1edabd8 --- /dev/null +++ b/src/lib/hook-installers-opencode.test.ts @@ -0,0 +1,77 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + installOpencode2Hooks, + isOpencode2PluginInstalled, + uninstallOpencode2Hooks, +} from "./hook-installers-opencode.js"; +import { OPENCODE_PLUGIN_MARKER } from "./opencode-plugin-source.js"; + +describe("OpenCode plugin installer", () => { + 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(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); + }); + + 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-opencode2.js"), `// ${OPENCODE_PLUGIN_MARKER}\n`); + installOpencode2Hooks(cwd, ""); + expect(existsSync(join(dir, "interlinked-opencode2.ts"))).toBe(true); + expect(existsSync(join(dir, "interlinked-opencode2.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..5f61d3d4 --- /dev/null +++ b/src/lib/hook-installers-opencode.ts @@ -0,0 +1,90 @@ +// =========================================== +// 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 { dirname, join } from "node:path"; +import { opencodeUserPluginDir } from "./opencode-runtime.js"; +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") { + return join(opencodeUserPluginDir(), OPENCODE_PLUGIN_FILENAME); + } + 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 project = getOpencodePluginPath(cwd, "project"); + const projectDir = dirname(project); + return [project, join(projectDir, "interlinked-opencode2.js")]; +} + +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"); + writePlugin(project); + for (const leftover of pluginPaths(cwd)) { + if (leftover === project) 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-runtime.test.ts b/src/lib/opencode-plugin-runtime.test.ts new file mode 100644 index 00000000..8a32e730 --- /dev/null +++ b/src/lib/opencode-plugin-runtime.test.ts @@ -0,0 +1,171 @@ +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 / 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); + 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 new file mode 100644 index 00000000..4ac2f77d --- /dev/null +++ b/src/lib/opencode-plugin-source.ts @@ -0,0 +1,313 @@ +// =========================================== +// 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. + +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"; + +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; + if (env.INTERLINKED_CLIENT === "opencode2" || Boolean(env.OPENCODE2)) return true; + if (env.INTERLINKED_CLIENT === "opencode") 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 (/${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)) { + return "BLOCKED: Package installs require the Interlinked daemon (allowlist). Start it with \`interlinked harness start\`."; + } + 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) return Promise.resolve(unavailable(event)); + 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(() => finish(unavailable(event)), 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 { + const parsed = JSON.parse(buf.slice(0, nl)); + finish(parsed && typeof parsed === "object" ? parsed : unavailable(event)); + } catch { + finish(unavailable(event)); + } + }); + socket.on("error", () => finish(unavailable(event))); + socket.on("close", () => { + if (!settled) finish(unavailable(event)); + }); + }); +} + +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"); + } +} + +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 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); + const decision = 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, + }); + applyFindings(event, decision); +} + +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.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 new file mode 100644 index 00000000..a2590d91 --- /dev/null +++ b/src/lib/opencode-runtime.ts @@ -0,0 +1,26 @@ +// 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.INTERLINKED_CLIENT === "opencode2") return true; + 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.test.ts b/src/lib/opencode-tool-map.test.ts new file mode 100644 index 00000000..97a00440 --- /dev/null +++ b/src/lib/opencode-tool-map.test.ts @@ -0,0 +1,67 @@ +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/); + expect(opencode2ColdBlockReason("Bash", { command: "rm -fr /" })).toMatch(/BLOCKED/); + expect(opencode2ColdBlockReason("Bash", { command: "rm -fr ~" })).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..d807bb9b --- /dev/null +++ b/src/lib/opencode-tool-map.ts @@ -0,0 +1,130 @@ +// =========================================== +// 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 }; + } +} + +/** 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/; + +/** 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 (OPENCODE2_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..830cce25 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", @@ -125,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 @@ -149,6 +166,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.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 e50c6bb2..692e5f1d 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 @@ -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. */ 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 }, 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({