fix: opencode permission and tool wiring for edit execution

This commit is contained in:
nrslib 2026-02-11 11:31:38 +09:00
parent 15fc6875e2
commit ccca0949ae
5 changed files with 466 additions and 30 deletions

View File

@ -290,10 +290,13 @@ describe('OpenCodeClient stream cleanup', () => {
expect(result.status).toBe('error');
expect(result.content).toContain('no question handler');
expect(questionReject).toHaveBeenCalledWith({
expect(questionReject).toHaveBeenCalledWith(
{
requestID: 'q-1',
directory: '/tmp',
});
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('should answer question.asked when handler is configured', async () => {
@ -350,10 +353,200 @@ describe('OpenCodeClient stream cleanup', () => {
});
expect(result.status).toBe('done');
expect(questionReply).toHaveBeenCalledWith({
expect(questionReply).toHaveBeenCalledWith(
{
requestID: 'q-2',
directory: '/tmp',
answers: [['A']],
});
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('should pass mapped tools to promptAsync when allowedTools is set', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([
{
type: 'message.updated',
properties: {
info: {
sessionID: 'session-tools',
role: 'assistant',
time: { created: Date.now(), completed: Date.now() + 1 },
},
},
},
]);
const promptAsync = vi.fn().mockResolvedValue(undefined);
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-tools' } });
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const subscribe = vi.fn().mockResolvedValue({ stream });
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe },
permission: { reply: vi.fn() },
},
server: { close: vi.fn() },
});
const client = new OpenCodeClient();
const result = await client.call('coder', 'hello', {
cwd: '/tmp',
model: 'opencode/big-pickle',
allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch', 'mcp__github__search'],
});
expect(result.status).toBe('done');
expect(promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
read: true,
edit: true,
bash: true,
websearch: true,
webfetch: true,
mcp__github__search: true,
},
}),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('should configure allow permissions for edit mode', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([
{
type: 'message.updated',
properties: {
info: {
sessionID: 'session-perm',
role: 'assistant',
time: { created: Date.now(), completed: Date.now() + 1 },
},
},
},
]);
const promptAsync = vi.fn().mockResolvedValue(undefined);
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm' } });
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const subscribe = vi.fn().mockResolvedValue({ stream });
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe },
permission: { reply: vi.fn() },
},
server: { close: vi.fn() },
});
const client = new OpenCodeClient();
await client.call('coder', 'hello', {
cwd: '/tmp',
model: 'opencode/big-pickle',
permissionMode: 'edit',
});
const createCallArgs = createOpencodeMock.mock.calls[0]?.[0] as { config?: Record<string, unknown> };
const permission = createCallArgs.config?.permission as Record<string, string>;
expect(permission.read).toBe('allow');
expect(permission.edit).toBe('allow');
expect(permission.write).toBe('allow');
expect(permission.bash).toBe('allow');
expect(permission.question).toBe('deny');
});
it('should pass permission ruleset to session.create', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([
{
type: 'message.updated',
properties: {
info: {
sessionID: 'session-ruleset',
role: 'assistant',
time: { created: Date.now(), completed: Date.now() + 1 },
},
},
},
]);
const promptAsync = vi.fn().mockResolvedValue(undefined);
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-ruleset' } });
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const subscribe = vi.fn().mockResolvedValue({ stream });
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe },
permission: { reply: vi.fn() },
},
server: { close: vi.fn() },
});
const client = new OpenCodeClient();
await client.call('coder', 'hello', {
cwd: '/tmp',
model: 'opencode/big-pickle',
permissionMode: 'edit',
});
expect(sessionCreate).toHaveBeenCalledWith(expect.objectContaining({
directory: '/tmp',
permission: expect.arrayContaining([
expect.objectContaining({ permission: 'edit', action: 'allow' }),
expect.objectContaining({ permission: 'question', action: 'deny' }),
]),
}));
});
it('should fail fast when permission reply times out', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([
{
type: 'permission.asked',
properties: {
id: 'perm-1',
sessionID: 'session-perm-timeout',
},
},
]);
const promptAsync = vi.fn().mockResolvedValue(undefined);
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm-timeout' } });
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const subscribe = vi.fn().mockResolvedValue({ stream });
const permissionReply = vi.fn().mockImplementation(() => new Promise(() => {}));
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe },
permission: { reply: permissionReply },
},
server: { close: vi.fn() },
});
const client = new OpenCodeClient();
const result = await Promise.race([
client.call('coder', 'hello', {
cwd: '/tmp',
model: 'opencode/big-pickle',
permissionMode: 'edit',
}),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), 8000)),
]);
expect(result.status).toBe('error');
expect(result.content).toContain('permission reply timed out');
});
});

View File

@ -3,7 +3,12 @@
*/
import { describe, it, expect } from 'vitest';
import { mapToOpenCodePermissionReply } from '../infra/opencode/types.js';
import {
buildOpenCodePermissionConfig,
buildOpenCodePermissionRuleset,
mapToOpenCodePermissionReply,
mapToOpenCodeTools,
} from '../infra/opencode/types.js';
import type { PermissionMode } from '../core/models/index.js';
describe('mapToOpenCodePermissionReply', () => {
@ -28,3 +33,52 @@ describe('mapToOpenCodePermissionReply', () => {
});
});
});
describe('mapToOpenCodeTools', () => {
it('should map built-in tool names to OpenCode tool IDs', () => {
expect(mapToOpenCodeTools(['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch'])).toEqual({
read: true,
edit: true,
bash: true,
websearch: true,
webfetch: true,
});
});
it('should keep unknown tool names as-is', () => {
expect(mapToOpenCodeTools(['mcp__github__search', 'custom_tool'])).toEqual({
mcp__github__search: true,
custom_tool: true,
});
});
it('should return undefined when tools are not provided', () => {
expect(mapToOpenCodeTools(undefined)).toBeUndefined();
expect(mapToOpenCodeTools([])).toBeUndefined();
});
});
describe('OpenCode permissions', () => {
it('should build allow config for full mode', () => {
expect(buildOpenCodePermissionConfig('full')).toBe('allow');
});
it('should build deny config for readonly mode', () => {
expect(buildOpenCodePermissionConfig('readonly')).toBe('deny');
});
it('should build ruleset for edit mode', () => {
const ruleset = buildOpenCodePermissionRuleset('edit');
expect(ruleset.length).toBeGreaterThan(0);
expect(ruleset.find((rule) => rule.permission === 'edit')).toEqual({
permission: 'edit',
pattern: '**',
action: 'allow',
});
expect(ruleset.find((rule) => rule.permission === 'question')).toEqual({
permission: 'question',
pattern: '**',
action: 'deny',
});
});
});

View File

@ -10,7 +10,13 @@ import { createServer } from 'node:net';
import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { parseProviderModel } from '../../shared/utils/providerModel.js';
import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js';
import {
buildOpenCodePermissionConfig,
buildOpenCodePermissionRuleset,
mapToOpenCodePermissionReply,
mapToOpenCodeTools,
type OpenCodeCallOptions,
} from './types.js';
import {
type OpenCodeStreamEvent,
type OpenCodePart,
@ -29,6 +35,7 @@ const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted';
const OPENCODE_RETRY_MAX_ATTEMPTS = 3;
const OPENCODE_RETRY_BASE_DELAY_MS = 250;
const OPENCODE_INTERACTION_TIMEOUT_MS = 5000;
const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
'stream disconnected before completion',
'transport error',
@ -41,6 +48,31 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
'failed to start server on port',
];
async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
timeoutErrorMessage: string,
): Promise<T> {
const controller = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
controller.abort();
reject(new Error(timeoutErrorMessage));
}, timeoutMs);
});
try {
return await Promise.race([
operation(controller.signal),
timeoutPromise,
]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
function extractOpenCodeErrorMessage(error: unknown): string | undefined {
if (!error || typeof error !== 'object') {
return undefined;
@ -256,10 +288,11 @@ export class OpenCodeClient {
const parsedModel = parseProviderModel(options.model, 'OpenCode model');
const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`;
const port = await getFreePort();
const permission = buildOpenCodePermissionConfig(options.permissionMode);
const config = {
model: fullModel,
small_model: fullModel,
permission: { question: 'deny' as const },
permission,
...(options.opencodeApiKey
? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } }
: {}),
@ -274,7 +307,10 @@ export class OpenCodeClient {
const sessionResult = options.sessionId
? { data: { id: options.sessionId } }
: await client.session.create({ directory: options.cwd });
: await client.session.create({
directory: options.cwd,
permission: buildOpenCodePermissionRuleset(options.permissionMode),
});
const sessionId = sessionResult.data?.id;
if (!sessionId) {
@ -286,11 +322,13 @@ export class OpenCodeClient {
);
resetIdleTimeout();
const tools = mapToOpenCodeTools(options.allowedTools);
await client.session.promptAsync(
{
sessionID: sessionId,
directory: options.cwd,
model: parsedModel,
...(tools ? { tools } : {}),
parts: [{ type: 'text' as const, text: fullPrompt }],
},
{ signal: streamAbortController.signal },
@ -348,11 +386,15 @@ export class OpenCodeClient {
const reply = options.permissionMode
? mapToOpenCodePermissionReply(options.permissionMode)
: 'once';
await client.permission.reply({
await withTimeout(
(signal) => client.permission.reply({
requestID: permProps.id,
directory: options.cwd,
reply,
});
}, { signal }),
OPENCODE_INTERACTION_TIMEOUT_MS,
'OpenCode permission reply timed out',
);
}
continue;
}
@ -361,10 +403,14 @@ export class OpenCodeClient {
const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties;
if (questionProps.sessionID === sessionId) {
if (!options.onAskUserQuestion) {
await client.question.reject({
await withTimeout(
(signal) => client.question.reject({
requestID: questionProps.id,
directory: options.cwd,
});
}, { signal }),
OPENCODE_INTERACTION_TIMEOUT_MS,
'OpenCode question reject timed out',
);
success = false;
failureMessage = 'OpenCode asked a question, but no question handler is configured';
break;
@ -372,16 +418,24 @@ export class OpenCodeClient {
try {
const answers = await options.onAskUserQuestion(toQuestionInput(questionProps));
await client.question.reply({
await withTimeout(
(signal) => client.question.reply({
requestID: questionProps.id,
directory: options.cwd,
answers: toQuestionAnswers(questionProps, answers),
});
}, { signal }),
OPENCODE_INTERACTION_TIMEOUT_MS,
'OpenCode question reply timed out',
);
} catch {
await client.question.reject({
await withTimeout(
(signal) => client.question.reject({
requestID: questionProps.id,
directory: options.cwd,
});
}, { signal }),
OPENCODE_INTERACTION_TIMEOUT_MS,
'OpenCode question reject timed out',
);
success = false;
failureMessage = 'OpenCode question handling failed';
break;

View File

@ -8,6 +8,7 @@ import type { PermissionMode } from '../../core/models/index.js';
/** OpenCode permission reply values */
export type OpenCodePermissionReply = 'once' | 'always' | 'reject';
export type OpenCodePermissionAction = 'ask' | 'allow' | 'deny';
/** Map TAKT PermissionMode to OpenCode permission reply */
export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePermissionReply {
@ -19,6 +20,138 @@ export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePerm
return mapping[mode];
}
const OPEN_CODE_PERMISSION_KEYS = [
'read',
'glob',
'grep',
'edit',
'write',
'bash',
'task',
'websearch',
'webfetch',
'question',
] as const;
export type OpenCodePermissionKey = typeof OPEN_CODE_PERMISSION_KEYS[number];
export type OpenCodePermissionMap = Record<OpenCodePermissionKey, OpenCodePermissionAction>;
function buildPermissionMap(mode?: PermissionMode): OpenCodePermissionMap {
const allDeny: OpenCodePermissionMap = {
read: 'deny',
glob: 'deny',
grep: 'deny',
edit: 'deny',
write: 'deny',
bash: 'deny',
task: 'deny',
websearch: 'deny',
webfetch: 'deny',
question: 'deny',
};
if (mode === 'readonly') return allDeny;
if (mode === 'full') {
return {
...allDeny,
read: 'allow',
glob: 'allow',
grep: 'allow',
edit: 'allow',
write: 'allow',
bash: 'allow',
task: 'allow',
websearch: 'allow',
webfetch: 'allow',
question: 'allow',
};
}
if (mode === 'edit') {
return {
...allDeny,
read: 'allow',
glob: 'allow',
grep: 'allow',
edit: 'allow',
write: 'allow',
bash: 'allow',
task: 'allow',
websearch: 'allow',
webfetch: 'allow',
question: 'deny',
};
}
return {
...allDeny,
read: 'ask',
glob: 'ask',
grep: 'ask',
edit: 'ask',
write: 'ask',
bash: 'ask',
task: 'ask',
websearch: 'ask',
webfetch: 'ask',
question: 'deny',
};
}
export function buildOpenCodePermissionConfig(mode?: PermissionMode): OpenCodePermissionAction | Record<string, OpenCodePermissionAction> {
if (mode === 'readonly') return 'deny';
if (mode === 'full') return 'allow';
return buildPermissionMap(mode);
}
export function buildOpenCodePermissionRuleset(mode?: PermissionMode): Array<{ permission: string; pattern: string; action: OpenCodePermissionAction }> {
const permissionMap = buildPermissionMap(mode);
return OPEN_CODE_PERMISSION_KEYS.map((permission) => ({
permission,
pattern: '**',
action: permissionMap[permission],
}));
}
const BUILTIN_TOOL_MAP: Record<string, string> = {
Read: 'read',
Glob: 'glob',
Grep: 'grep',
Edit: 'edit',
Write: 'write',
Bash: 'bash',
WebSearch: 'websearch',
WebFetch: 'webfetch',
};
export function mapToOpenCodeTools(allowedTools?: string[]): Record<string, boolean> | undefined {
if (!allowedTools || allowedTools.length === 0) {
return undefined;
}
const mapped = new Set<string>();
for (const tool of allowedTools) {
const normalized = tool.trim();
if (!normalized) {
continue;
}
const mappedTool = BUILTIN_TOOL_MAP[normalized] ?? normalized;
mapped.add(mappedTool);
}
if (mapped.size === 0) {
return undefined;
}
const tools: Record<string, boolean> = {};
for (const tool of mapped) {
tools[tool] = true;
}
return tools;
}
/** Options for calling OpenCode */
export interface OpenCodeCallOptions {
cwd: string;
@ -26,6 +159,7 @@ export interface OpenCodeCallOptions {
sessionId?: string;
model: string;
systemPrompt?: string;
allowedTools?: string[];
/** Permission mode for automatic permission handling */
permissionMode?: PermissionMode;
/** Enable streaming mode with callback (best-effort) */

View File

@ -17,6 +17,7 @@ function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions {
abortSignal: options.abortSignal,
sessionId: options.sessionId,
model: options.model,
allowedTools: options.allowedTools,
permissionMode: options.permissionMode,
onStream: options.onStream,
onAskUserQuestion: options.onAskUserQuestion,