Skip to content

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

@vishal-android-freak

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions