Skip to content

POST /permission/{requestID}/reply returns 200/true for non-existent permission IDs #15386

Closed as not planned

Description

@vishal-android-freak

Description

Two issues found:

  1. 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.
  2. 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

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Feb 27, 2026
  2. vishal-android-freak commented on Feb 27, 2026

    @vishal-android-freak
    Author

    Why are the permissions not persisted during server restarts? I am integrating opencode in my mobile app using the opencode-ai/sdk/v2 and the app stores the pending permissions locally. When I try to respond to the request by killing and restarting the server, nothing happens and the issue is mentioned above. @thdxr am I doing anything wrong here?

  3. vishal-android-freak commented on Mar 7, 2026

    @vishal-android-freak
    Author

    @thdxr any plans to support this?

  4. assigned and unassigned on Apr 26, 2026
  5. removed
    bugSomething isn't working
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  6. github-actions commented on Jul 3, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions