Description
Two issues found:
- Pending permissions are not persisted across server restarts. GET /permission returns an empty array after restarting opencode serve, even
though a session was blocked waiting for permission before the restart.
- The reply endpoint silently succeeds for stale/non-existent permission IDs. POST /permission/{requestID}/reply returns 200 with data: true when
given a permission ID that doesn't exist on the server. It should return 404 so clients know the permission was lost and can take appropriate
action (e.g., re-prompt the user).
The repro script demonstrates the full flow — creates a session, triggers a permission, kills the server, starts a new one, shows permissions are
gone, and shows the reply still returns 200.
Plugins
none
OpenCode version
No response
Steps to reproduce
/**
* Reproduction script: OpenCode permission reply returns 200 but does nothing
* after server restart.
*
* Steps:
* 1. Start opencode server
* 2. Create session, send a prompt that triggers a permission request
* 3. Wait for permission.asked SSE event
* 4. Kill the server and start a new one
* 5. List pending permissions on the new server (shows empty)
* 6. Reply to the permission — returns 200/true but nothing happens
*
* Expected: Either the permission should be persisted and processing should
* resume, OR the reply should return 404 (not silently return 200).
*
* Run: bun run src/__tests__/services/opencode-permission-repro.ts
*/
import { createOpencode, createOpencodeClient } from '@opencode-ai/sdk/v2';
const PORT1 = 4199; // First server
const PORT2 = 4200; // Second server (after restart)
const HOSTNAME = '127.0.0.1';
const DIRECTORY = process.cwd(); // Any valid directory
async function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function main() {
console.log('=== OpenCode Permission Persistence Repro ===\n');
// Step 1: Start server
console.log('1. Starting opencode server...');
let { client, server } = await createOpencode({
hostname: HOSTNAME,
port: PORT1,
timeout: 30000,
});
console.log(` Server started at ${server.url}\n`);
// Step 2: Create a session with all permissions set to "ask"
console.log('2. Creating session with permission mode "ask"...');
const sessionResult = await client.session.create({
directory: DIRECTORY,
permission: [{ permission: '*', pattern: '*', action: 'ask' }],
});
if (sessionResult.error) {
console.error(' Failed to create session:', sessionResult.error);
server.close();
process.exit(1);
}
const sessionId = sessionResult.data!.id;
console.log(` Session created: ${sessionId}\n`);
// Step 3: Start SSE listener and send a prompt that will trigger a permission
console.log('3. Starting SSE listener and sending prompt...');
let permissionRequest: any = null;
// Listen for events
const eventPromise = (async () => {
const events = await client.global.event();
if (!events.stream) {
console.error(' No SSE stream returned');
return;
}
for await (const rawEvent of events.stream) {
const event = (rawEvent as any)?.payload || rawEvent;
const type = event.type;
// Log ALL events for debugging
if (type !== 'server.heartbeat') {
console.log(` SSE: ${type}`, JSON.stringify(event.properties || {}).substring(0, 200));
}
if (type === 'permission.asked') {
permissionRequest = event.properties;
console.log(` Got permission.asked:`, {
id: permissionRequest.id || permissionRequest.requestID,
permission: permissionRequest.permission,
patterns: permissionRequest.patterns,
});
break; // Got what we need
}
}
})();
// Send a prompt that will trigger a bash permission request
const promptResult = await client.session.promptAsync({
sessionID: sessionId,
directory: DIRECTORY,
model: { providerID: 'opencode', modelID: 'minimax-m2.5-free' },
parts: [{ type: 'text', text: 'Use the Bash tool to run this exact command: echo "hello from opencode test". Do not explain, just run it.' }],
});
console.log(' promptAsync result:', { data: promptResult.data, error: promptResult.error, status: promptResult.response?.status });
if (promptResult.error) {
console.error(' Failed to send prompt:', promptResult.error);
server.close();
process.exit(1);
}
console.log(' Prompt sent, waiting for permission request...');
// Wait for the permission event (with timeout)
const timeout = setTimeout(() => {
if (!permissionRequest) {
console.error(' Timed out waiting for permission.asked event (30s)');
console.log(' Note: The model might not have triggered a tool call that needs permission.');
console.log(' Try running again or use a different prompt.');
server.close();
process.exit(1);
}
}, 30000);
await eventPromise;
clearTimeout(timeout);
if (!permissionRequest) {
console.error(' No permission request received');
server.close();
process.exit(1);
}
const requestId = permissionRequest.id || permissionRequest.requestID;
console.log(`\n Permission request ID: ${requestId}`);
// Step 4: List pending permissions BEFORE restart
console.log('\n4. Listing pending permissions BEFORE server restart...');
const beforePerms = await client.permission.list({ directory: DIRECTORY });
console.log(` Pending permissions: ${JSON.stringify(beforePerms.data, null, 2)}`);
// Step 5: Kill server and start a new one
console.log('\n5. Killing server and starting a new one...');
server.close();
await sleep(2000); // Wait for process to die
const newServer = await createOpencode({
hostname: HOSTNAME,
port: PORT2,
timeout: 30000,
});
client = newServer.client;
server = newServer.server;
console.log(` New server started at ${server.url}`);
// Step 6: List pending permissions AFTER restart
console.log('\n6. Listing pending permissions AFTER server restart...');
const afterPerms = await client.permission.list({ directory: DIRECTORY });
console.log(` Pending permissions: ${JSON.stringify(afterPerms.data, null, 2)}`);
// Step 7: Try to reply to the permission on the new server
console.log(`\n7. Replying to permission ${requestId} on new server...`);
const replyResult = await client.permission.reply({
requestID: requestId,
directory: DIRECTORY,
reply: 'once',
});
console.log(` Reply result:`, {
data: replyResult.data,
error: replyResult.error,
status: replyResult.response?.status,
});
// Step 8: Wait a bit and check if anything happens
console.log('\n8. Waiting 5s for any SSE events from new server...');
let gotEvents = false;
const newEventPromise = (async () => {
const events = await client.global.event();
if (!events.stream) return;
const eventTimeout = setTimeout(() => {}, 5000);
for await (const rawEvent of events.stream) {
const event = (rawEvent as any)?.payload || rawEvent;
if (event.type !== 'server.heartbeat') {
console.log(` SSE event: ${event.type}`, event.properties?.sessionID ? `(session: ${event.properties.sessionID})` : '');
gotEvents = true;
}
}
clearTimeout(eventTimeout);
})();
await sleep(5000);
if (!gotEvents) {
console.log(' No non-heartbeat SSE events received.');
}
// Summary
console.log('\n=== SUMMARY ===');
console.log(`Permission existed before restart: ${(beforePerms.data as any[])?.length > 0 ? 'YES' : 'NO (empty)'}`);
console.log(`Permission exists after restart: ${(afterPerms.data as any[])?.length > 0 ? 'YES' : 'NO (lost)'}`);
console.log(`Reply status code: ${replyResult.response?.status}`);
console.log(`Reply data: ${JSON.stringify(replyResult.data)}`);
console.log(`Reply error: ${JSON.stringify(replyResult.error)}`);
console.log(`Agent resumed after reply: ${gotEvents ? 'YES' : 'NO'}`);
if (replyResult.response?.status === 200 && !gotEvents) {
console.log('\n*** BUG: Server returned 200 for permission reply but agent did NOT resume. ***');
console.log('*** Pending permissions are lost on server restart. The reply should return ***');
console.log('*** 404 or an error instead of silently accepting a stale permission ID. ***');
}
server.close();
process.exit(0);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
Screenshot and/or share link
No response
Operating System
windows 11
Terminal
warp
Description
Two issues found:
though a session was blocked waiting for permission before the restart.
given a permission ID that doesn't exist on the server. It should return 404 so clients know the permission was lost and can take appropriate
action (e.g., re-prompt the user).
The repro script demonstrates the full flow — creates a session, triggers a permission, kills the server, starts a new one, shows permissions are
gone, and shows the reply still returns 200.
Plugins
none
OpenCode version
No response
Steps to reproduce
Screenshot and/or share link
No response
Operating System
windows 11
Terminal
warp