From 84881faeb946b8279ab45cfbc8956d4562ba6304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Thu, 23 Apr 2026 00:04:08 +0200 Subject: [PATCH 1/2] feat(opencode-plugin): permissions system --- packages/opencode-plugin/src/plugin.ts | 18 +++- .../opencode-plugin/test/e2e/plugin.test.ts | 83 ++++++++++++------- 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 6a04fcd8..ec416484 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -641,7 +641,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin * an error if the agent is not allowed to use workflows. */ tool: await (async (): Promise<{ [key: string]: ToolDefinition }> => { - const wrap = (def: ToolDefinition): ToolDefinition => ({ + const wrap = (toolName: string, def: ToolDefinition): ToolDefinition => ({ ...def, execute: async (args, ctx) => { const agent = ctx.agent; @@ -652,12 +652,20 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin ); } + await ctx.ask({ + permission: toolName, + patterns: ['*'], + always: ['*'], + metadata: {}, + }); + return def.execute(args, ctx); }, }); return { start_development: wrap( + 'start_development', createStartDevelopmentTool( input.directory, getServerContext, @@ -665,6 +673,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin ) ), proceed_to_phase: wrap( + 'proceed_to_phase', createProceedToPhaseTool( getServerContext, setBufferedInstructions, @@ -672,11 +681,16 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin () => lastKnownModel ) ), - conduct_review: wrap(createConductReviewTool(getServerContext)), + conduct_review: wrap( + 'conduct_review', + createConductReviewTool(getServerContext) + ), reset_development: wrap( + 'reset_development', createResetDevelopmentTool(input.directory, getServerContext) ), setup_project_docs: wrap( + 'setup_project_docs', await createSetupProjectDocsTool(input.directory, getServerContext) ), }; diff --git a/packages/opencode-plugin/test/e2e/plugin.test.ts b/packages/opencode-plugin/test/e2e/plugin.test.ts index f8483fac..999be9cf 100644 --- a/packages/opencode-plugin/test/e2e/plugin.test.ts +++ b/packages/opencode-plugin/test/e2e/plugin.test.ts @@ -50,6 +50,24 @@ function cleanupDir(dir: string): void { } } +/** + * Create a mock ToolContext for testing. + * Includes a no-op `ask` spy so permission checks in the plugin's `wrap()` work. + */ +function createMockToolContext(overrides: Record = {}) { + return { + sessionID: 'test-session', + messageID: 'test-message', + agent: 'workflow', + directory: '', + worktree: '', + abort: new AbortController().signal, + metadata: vi.fn(), + ask: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + /** * Create a mock PluginInput for testing */ @@ -571,7 +589,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.start_development.execute( { workflow: 'epcc' }, - {} as never + createMockToolContext() ); // start_development returns instructions from handler @@ -592,7 +610,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.start_development.execute( { workflow: 'waterfall' }, - {} as never + createMockToolContext() ); // When trying to start a different workflow, we get an error about existing workflow @@ -615,7 +633,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.proceed_to_phase.execute( { target_phase: 'plan', reason: 'exploration complete' }, - {} as never + createMockToolContext() ); // Transition output now shows the new phase clearly @@ -645,7 +663,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const sessionID = 'test-session-123'; await hooks.tool!.proceed_to_phase.execute( { target_phase: 'plan', reason: 'exploration complete' }, - { sessionID } as never + createMockToolContext({ sessionID }) ); // session.summarize should have been called with the session ID and model @@ -664,9 +682,10 @@ describe('OpenCode Workflows Plugin E2E', () => { hooks = await WorkflowsPlugin(mockInput); const sessionID = 'test-session-456'; - await hooks.tool!.proceed_to_phase.execute({ target_phase: 'plan' }, { - sessionID, - } as never); + await hooks.tool!.proceed_to_phase.execute( + { target_phase: 'plan' }, + createMockToolContext({ sessionID }) + ); // session.summarize should NOT have been called — transition failed const summarizeMock = ( @@ -708,7 +727,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const sessionID = 'test-session-789'; const proceedResult = await hooks.tool!.proceed_to_phase.execute( { target_phase: 'plan', reason: 'exploration complete' }, - { sessionID } as never + createMockToolContext({ sessionID }) ); // Verify the transition itself succeeded before checking compaction was skipped @@ -728,7 +747,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.proceed_to_phase.execute( { target_phase: 'plan' }, - {} as never + createMockToolContext() ); // Error message from handler mentions "No development conversation" or similar @@ -750,7 +769,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.reset_development.execute( { confirm: false }, - {} as never + createMockToolContext() ); expect(result).toContain('confirm'); @@ -766,7 +785,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.reset_development.execute( { confirm: true, reason: 'testing reset' }, - {} as never + createMockToolContext() ); // Reset message confirms deletion (may not include workflow name) @@ -778,7 +797,7 @@ describe('OpenCode Workflows Plugin E2E', () => { const result = await hooks.tool!.reset_development.execute( { confirm: true }, - {} as never + createMockToolContext() ); expect(result).toContain('No active workflow'); @@ -921,10 +940,10 @@ describe('WORKFLOW_AGENTS environment variable', () => { // Tool should throw when agent is not in filter await expect( - hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - agent: 'explore', // Not in filter - } as unknown) + hooks.tool!['start_development'].execute( + { workflow: 'minor' }, + createMockToolContext({ agent: 'explore' }) // Not in filter + ) ).rejects.toThrow(/not enabled for this agent/i); } finally { if (originalEnv === undefined) { @@ -948,10 +967,10 @@ describe('WORKFLOW_AGENTS environment variable', () => { // (it may fail for other reasons like no plan file, but not the filter guard) let thrownMessage: string | undefined; try { - await hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - agent: 'general', // In filter - } as unknown); + await hooks.tool!['start_development'].execute( + { workflow: 'minor' }, + createMockToolContext({ agent: 'general' }) // In filter + ); } catch (err) { thrownMessage = (err as Error).message; } @@ -989,10 +1008,10 @@ describe('WORKFLOW_AGENTS environment variable', () => { // Tool should not throw agent filter error for any agent let thrownMessage: string | undefined; try { - await hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - agent: 'any-agent', // Should work when no filter is set - } as unknown); + await hooks.tool!['start_development'].execute( + { workflow: 'minor' }, + createMockToolContext({ agent: 'any-agent' }) // Should work when no filter is set + ); } catch (err) { thrownMessage = (err as Error).message; } @@ -1020,10 +1039,10 @@ describe('WORKFLOW_AGENTS environment variable', () => { // Should parse correctly and allow the whitelisted agents let thrownMessage: string | undefined; try { - await hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - agent: 'architect', // Should work after trimming - } as unknown); + await hooks.tool!['start_development'].execute( + { workflow: 'minor' }, + createMockToolContext({ agent: 'architect' }) // Should work after trimming + ); } catch (err) { thrownMessage = (err as Error).message; } @@ -1033,10 +1052,10 @@ describe('WORKFLOW_AGENTS environment variable', () => { // And reject agents not in the list await expect( - hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - agent: 'other-agent', // Not in list - } as unknown) + hooks.tool!['start_development'].execute( + { workflow: 'minor' }, + createMockToolContext({ agent: 'other-agent' }) // Not in list + ) ).rejects.toThrow(/not enabled for this agent/i); } finally { if (originalEnv === undefined) { From 1f68835e90cf4d73b18552c2c0e44cf894af0f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Thu, 23 Apr 2026 00:41:59 +0200 Subject: [PATCH 2/2] fix(opencode-plugin): use Effect.runPromise for ctx.ask permission check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin SDK types ctx.ask() as returning Effect.Effect, not Promise. Awaiting an Effect object directly is a no-op — the permission prompt was never triggered. Fix by importing effect as a peer/dev dependency and running the Effect via Effect.runPromise(). Update types.ts to match the real SDK type and fix test mocks to return Effect.void instead of a resolved Promise. --- packages/opencode-plugin/package.json | 7 +++- packages/opencode-plugin/src/plugin.ts | 15 ++++--- packages/opencode-plugin/src/types.ts | 4 +- .../opencode-plugin/test/e2e/plugin.test.ts | 3 +- pnpm-lock.yaml | 41 +++++++++++++++---- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json index 3df307e7..eede73f7 100644 --- a/packages/opencode-plugin/package.json +++ b/packages/opencode-plugin/package.json @@ -29,16 +29,21 @@ "devDependencies": { "@codemcp/workflows-core": "workspace:*", "@codemcp/workflows-server": "workspace:*", + "effect": "3.21.1", "rimraf": "^6.0.1", "tsup": "^8.0.0", "vitest": "4.0.18" }, "peerDependencies": { - "@anthropic-ai/sdk": "*" + "@anthropic-ai/sdk": "*", + "effect": ">=3.0.0" }, "peerDependenciesMeta": { "@anthropic-ai/sdk": { "optional": true + }, + "effect": { + "optional": true } }, "keywords": [ diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index ec416484..900e325d 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -13,6 +13,7 @@ */ import type { Plugin, PluginInput, Hooks, ToolDefinition } from './types.js'; +import { Effect } from 'effect'; import { createProceedToPhaseTool } from './tool-handlers/proceed-to-phase.js'; import { createConductReviewTool } from './tool-handlers/conduct-review.js'; import { createResetDevelopmentTool } from './tool-handlers/reset-development.js'; @@ -652,12 +653,14 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin ); } - await ctx.ask({ - permission: toolName, - patterns: ['*'], - always: ['*'], - metadata: {}, - }); + await Effect.runPromise( + ctx.ask({ + permission: toolName, + patterns: ['*'], + always: ['*'], + metadata: {}, + }) + ); return def.execute(args, ctx); }, diff --git a/packages/opencode-plugin/src/types.ts b/packages/opencode-plugin/src/types.ts index b6ea0036..99d00367 100644 --- a/packages/opencode-plugin/src/types.ts +++ b/packages/opencode-plugin/src/types.ts @@ -51,6 +51,8 @@ export type PluginInput = { }; // Tool context for custom tools +import type { Effect } from 'effect'; + export type ToolContext = { sessionID: string; messageID: string; @@ -64,7 +66,7 @@ export type ToolContext = { patterns: string[]; always: string[]; metadata: Record; - }): Promise; + }): Effect.Effect; }; // Tool definition diff --git a/packages/opencode-plugin/test/e2e/plugin.test.ts b/packages/opencode-plugin/test/e2e/plugin.test.ts index 999be9cf..d0864cb2 100644 --- a/packages/opencode-plugin/test/e2e/plugin.test.ts +++ b/packages/opencode-plugin/test/e2e/plugin.test.ts @@ -6,6 +6,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Effect } from 'effect'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; @@ -63,7 +64,7 @@ function createMockToolContext(overrides: Record = {}) { worktree: '', abort: new AbortController().signal, metadata: vi.fn(), - ask: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockReturnValue(Effect.void), ...overrides, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99a86fac..af6221e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: '@codemcp/workflows-server': specifier: workspace:* version: link:../mcp-server + effect: + specifier: 3.21.1 + version: 3.21.1 rimraf: specifier: ^6.0.1 version: 6.1.2 @@ -2735,6 +2738,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@3.21.1: + resolution: {integrity: sha512-bA3TsBd0mByThuCnE6FQqS+L3DLazk4UbE9jp0CqVfIjQl5DKi8sAe93O/ZKeF7clL65fJxcsVGiAKYXdnHByA==} + electron-to-chromium@1.5.329: resolution: {integrity: sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==} @@ -2857,6 +2863,10 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3606,6 +3616,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -6175,11 +6188,6 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.31))(vue@3.5.22(typescript@5.9.3))': - dependencies: - vite: 5.4.21(@types/node@20.19.31) - vue: 3.5.22(typescript@5.9.3) - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.19.8))(vue@3.5.22(typescript@5.9.3))': dependencies: vite: 5.4.21(@types/node@22.19.8) @@ -6202,6 +6210,14 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@22.19.8)(tsx@4.21.0)(yaml@2.8.1) + '@vitest/mocker@4.0.18(vite@7.1.12(@types/node@24.7.0)(tsx@4.21.0)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@24.7.0)(tsx@4.21.0)(yaml@2.8.1) + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -6976,6 +6992,11 @@ snapshots: ee-first@1.1.1: {} + effect@3.21.1: + dependencies: + '@standard-schema/spec': 1.0.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.329: {} emoji-regex-xs@1.0.0: {} @@ -7187,6 +7208,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -7916,6 +7941,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -8600,7 +8627,7 @@ snapshots: '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@20.19.31))(vue@3.5.22(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@22.19.8))(vue@3.5.22(typescript@5.9.3)) '@vue/devtools-api': 7.7.7 '@vue/shared': 3.5.22 '@vueuse/core': 12.8.2(typescript@5.9.3) @@ -8730,7 +8757,7 @@ snapshots: vitest@4.0.18(@types/node@24.7.0)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.1.12(@types/node@22.19.8)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 4.0.18(vite@7.1.12(@types/node@24.7.0)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18