# Ticket 1.5 — Permission Bridge

**Phase**: 1 — OpenCode Bridge (Core)
**Priority**: Critical (blocking)
**Depends on**: 1.2, 1.4

## Description

Bridge OpenCode's permission system to the relay's WebSocket clients. When OpenCode asks for permission (e.g., run a bash command, edit a file, access external directory), the bridge receives a `permission.asked` event via SSE, forwards it to all connected browsers, collects the user's decision, and POSTs it back to OpenCode via `POST /permission/:requestID/reply`.

This is one of the most important flows — without it, the agent blocks indefinitely.

### OpenCode Permission Model

OpenCode's permissions differ from Claude Code's:
- **Decision values**: `once`, `always`, `reject` (NOT `allow`, `deny`, `allow_always`)
- **Request shape**: `{ id, sessionID, permission, patterns[], metadata, always[], tool? }` — permissions are categorized by type (e.g., `bash`, `edit`, `external_directory`) with glob patterns for file paths
- **Special permission types**: `doom_loop` (repeated identical calls), `external_directory` (out-of-workspace paths), `.env` files
- **List pending**: `GET /permission/` returns all pending permission requests
- **Reply endpoint**: `POST /permission/:requestID/reply` with `{ reply: "once"|"always"|"reject" }`

## Acceptance Criteria

### AC1: Receives permission request from SSE and forwards to browsers

```gherkin
Given the SSE consumer is connected and a browser is connected via WebSocket
When OpenCode emits a permission.asked event with properties:
  { id, sessionID, permission, patterns[], metadata, always[], tool? }
Then the bridge creates a pending permission entry with:
  | Field      | Value                                        |
  | requestId  | properties.id                                |
  | toolName   | properties.permission (e.g., "bash", "edit") |
  | toolInput  | { patterns: properties.patterns, metadata: properties.metadata } |
  | always     | properties.always (patterns already allowed) |
  | timestamp  | Current time                                 |
And it sends a permission_request message to all connected WebSocket clients
And it triggers a push notification (if push is enabled)
```

### AC2: Receives browser decision and forwards to OpenCode

```gherkin
Given a pending permission request with requestId "abc123"
When a browser sends { type: "permission_response", requestId: "abc123", decision: "allow" }
Then the bridge maps the decision: "allow" → "once", "allow_always" → "always", "deny" → "reject"
And it calls replyPermission("abc123", "once") on the REST client (POST /permission/abc123/reply)
And it removes the permission from the pending map
And it broadcasts { type: "permission_resolved", requestId: "abc123", decision: "allow" } to all clients
```

### AC3: Supports all decision types

```gherkin
Given a pending permission request
When the browser sends decision "allow"
Then the bridge maps to "once" and forwards to OpenCode via POST /permission/:id/reply

When the browser sends decision "deny"
Then the bridge maps to "reject" and forwards to OpenCode

When the browser sends decision "allow_always"
Then the bridge maps to "always" and forwards to OpenCode
And subsequent requests for the same tool+pattern are auto-approved

Note: The bridge translates between the frontend's decision vocabulary (allow/deny/allow_always)
and OpenCode's vocabulary (once/always/reject) to maintain frontend compatibility.
```

### AC4: Auto-approve for "always allowed" tools

```gherkin
Given the user previously chose "allow_always" for tool "read"
When a new permission request arrives for tool "read"
Then the bridge auto-approves it by calling replyPermission immediately
And it sends { type: "permission_resolved", requestId, decision: "allow" } to browsers
And it does NOT show an approval prompt to the user
```

### AC5: Permission timeout

```gherkin
Given a pending permission request
When no response is received within 5 minutes (configurable)
Then the bridge auto-denies the permission
And it calls replyPermission(requestId, "deny")
And it sends a notification: "Permission request timed out"
And it removes the permission from the pending map
```

### AC6: Multiple pending permissions

```gherkin
Given two permission requests arrive before the first is resolved
When the bridge processes them
Then both are tracked in the pending map with unique requestIds
And both are sent to browsers as separate permission_request messages
And each can be resolved independently
```

### AC7: Browser reconnection replays pending permissions

```gherkin
Given there are 2 pending permission requests
When a new browser connects via WebSocket
Then it receives both pending permission_request messages immediately
And the user can approve/deny them from the newly connected browser
```

### AC8: Duplicate response handling

```gherkin
Given a pending permission that has already been resolved
When a second browser sends a response for the same requestId
Then the bridge ignores the duplicate
And it does NOT call replyPermission again
And it logs a debug message about the duplicate
```

### AC9: Crash recovery — re-surfaces pending permissions after restart

```gherkin
Given the relay crashes or restarts while one or more permissions are pending
When the relay starts up and reconnects to OpenCode
Then it queries GET /permission/ to fetch all currently pending permission requests
And it populates its internal pending map with the fetched requests
And it sends permission_request messages to all connected WebSocket clients
And the user can approve/deny the previously-pending permissions from the browser
And OpenCode is NOT left blocked forever waiting for a reply

Note: OpenCode's GET /permission/ endpoint returns all unresolved permission requests,
making this recovery possible without the relay needing to persist its own state.
```

### AC10: Concurrent approval from OpenCode CLI

```gherkin
Given a permission is pending in both the relay UI and the OpenCode terminal
When the user approves the permission from the OpenCode CLI (not the relay)
Then a permission.replied SSE event is emitted
And the relay receives it and removes the permission from its pending map
And it broadcasts { type: "permission_resolved", requestId, decision } to browsers
And the browser's permission prompt disappears
And no error occurs from the relay trying to reply to an already-resolved permission
```

## Testable Components

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `onPermissionRequest(event)` | Unit | Creates pending entry, broadcasts to clients |
| `onPermissionResponse(requestId, decision)` | Unit | Calls REST client, removes pending, broadcasts resolved |
| `autoApprove(toolName)` | Unit | Checks always-allowed set, auto-replies |
| `checkTimeout()` | Unit | Auto-denies expired permissions |
| `getPending()` | Unit | Returns all pending permissions (for replay) |
| `handleDuplicate(requestId)` | Unit | Ignores, doesn't double-call API |
| Push notification trigger | Unit | Push sent on new permission request |
| `recoverPending()` | Unit | Fetches GET /permission/, populates pending map, broadcasts to clients |
| `onPermissionRepliedSSE(event)` | Unit | Removes from pending, broadcasts resolved (handles CLI-approved permissions) |
| End-to-end flow | Fixture | SSE event → browser prompt → browser response → REST reply |
| Timeout flow | Fixture | SSE event → wait → auto-deny → REST reply |
| Reconnection replay | Fixture | Connect → receive pending → respond → resolved |
| Crash recovery | Fixture | Restart relay → fetch pending → display to browser → approve → resolved |
| CLI approval race | Fixture | Permission asked → CLI approves → relay receives replied event → UI updated |

## API Shape

```ts
// src/lib/permission-bridge.ts
export class PermissionBridge {
  constructor(options = {})  // { restClient, timeoutMs, pushService }

  onPermissionRequest(event)                    // Called by SSE consumer
  onPermissionResponse(requestId, decision)     // Called by WebSocket handler
  getPending()                                  // Returns Map of pending permissions
  isAlwaysAllowed(toolName)                     // Check auto-approve cache
  clearAlwaysAllowed()                          // Reset auto-approve cache

  // Events emitted:
  // 'forward_to_clients' — { type: "permission_request", ... }
  // 'resolved'           — { type: "permission_resolved", ... }
  // 'timeout'            — { requestId, toolName }
}
```

## Reference

- Claude-relay permission flow: `../claude-relay/lib/sdk-bridge.js` (handleCanUseTool)
- Claude-relay pending map: `../claude-relay/lib/project.js` (pendingPermissions)
- OpenCode permission API: `POST /permission/{requestId}/reply` with `{ reply: "once"|"always"|"reject" }`
- OpenCode list pending: `GET /permission/` returns `PermissionNext.Request[]`
- OpenCode permission events: `permission.asked` and `permission.replied` SSE events
- OpenCode permission source: `../opencode/packages/opencode/src/permission/next.ts`
- Decision mapping: frontend allow → once, deny → reject, allow_always → always
