Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 429 additions & 0 deletions docs/design/daemon-workspace-remember.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/developers/daemon/00-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Pick the path that matches your goal:
- [`06-mcp-budget-guardrails.md`](./06-mcp-budget-guardrails.md) - `WorkspaceMcpBudget`, modes (`off`/`warn`/`enforce`), hysteresis, refused-batch coalescing.
- [`07-workspace-filesystem.md`](./07-workspace-filesystem.md) - `WorkspaceFileSystem` sandbox, path policy, audit, `BridgeFileSystem` contract.
- [`08-session-lifecycle.md`](./08-session-lifecycle.md) - create / attach / load / resume, `X-Qwen-Client-Id`, heartbeat, eviction, metadata.
- [`09-event-schema.md`](./09-event-schema.md) - typed event schema v1: all 43 known event types with payloads, reducers, forward compatibility.
- [`09-event-schema.md`](./09-event-schema.md) - typed event schema v1: all 47 known event types with payloads, reducers, forward compatibility.
- [`10-event-bus.md`](./10-event-bus.md) - `EventBus`, monotonic IDs, ring replay, `Last-Event-ID`, slow-client backpressure, `client_evicted`.
- [`11-capabilities-versioning.md`](./11-capabilities-versioning.md) - capability registry, protocol version, schema version, conditional advertisement.
- [`12-auth-security.md`](./12-auth-security.md) - bearer middleware, host allowlist, CORS deny, mutation gate, `--require-auth`, `/health` exemption, device flow.
Expand Down Expand Up @@ -125,8 +125,8 @@ Use these anchors when moving from the docs into the latest `main` code:
| Area | Current state | Primary docs |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) |
| Event schema | `EVENT_SCHEMA_VERSION = 1`; 43 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped at SSE write boundary. | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) |
| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 67 registered tags; 10 conditional tags. | [`11`](./11-capabilities-versioning.md) |
| Event schema | `EVENT_SCHEMA_VERSION = 1`; 47 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped at SSE write boundary. | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) |
| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 75 registered tags; 13 conditional tags. | [`11`](./11-capabilities-versioning.md) |
| Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer auth, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) |
| Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) |

Expand Down
109 changes: 108 additions & 1 deletion docs/developers/daemon/04-permission-mediation.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,109 @@ A future pair-token mechanism will issue a per-session secret from
`POST /session` and require it on `designated` / `consensus` votes. That
mechanism does not exist in v1.

## Cross-Connection Vote Routing

### Vote delivery paths

Permission votes can reach the bridge mediator through two independent transport paths:

1. **ACP transport (same-connection response)**: The `permission_request` bridge event is delivered to the owning connection's session-scoped SSE/WS stream as a `session/request_permission` JSON-RPC request. The client answers with a JSON-RPC response on the same connection. The dispatcher's `resolveClientResponse` maps the connection-local JSON-RPC id back to the bridge's `requestId` and calls `bridge.respondToSessionPermission`.

2. **REST API (cross-connection)**: Any HTTP client — including clients on a different ACP connection or with no ACP connection at all — can vote via `POST /session/:id/permission/:requestId`. The legacy `POST /permission/:requestId` route (no session in the URL) uses `peekSessionFor(requestId)` to resolve the session before delegating to the same `respondToSessionPermission` path.

### Connection-local permission request IDs

The ACP transport uses a two-level ID scheme to map between the wire and the bridge:

| Layer | ID format | Scope | Purpose |
| ------------------- | ---------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------- |
| JSON-RPC message id | `_qwen_perm_N` (string, monotonic per connection) | Connection-local | Correlates the JSON-RPC request→response pair on the session stream. |
| Bridge request id | Opaque string (UUID generated by the agent/mediator) | Daemon-global | Identifies the permission request across all routes and the mediator's pending/resolved maps. |

The bridge request id is threaded through the `_meta` vendor extension so the client can include it when voting via the REST path:

```json
{
"method": "session/request_permission",
"id": "_qwen_perm_3",
"params": {
"sessionId": "<session-id>",
"toolCall": { "name": "shell" },
"options": [{ "optionId": "allow", "name": "Allow" }],
"_meta": { "qwen": { "requestId": "<bridge-request-id>" } }
}
}
```

The connection stores the mapping in `conn.pending: Map<jsonRpcId, PendingClientRequest>`, where `PendingClientRequest.bridgeRequestId` is the bridge-level id.

### Vote authorization rules

`respondToSessionPermission(sessionId, requestId, response, context)` applies the following checks **in order**:

1. **Session existence** — the session addressed by `sessionId` must be live (`byId.has(sessionId)`). Otherwise `SessionNotFoundError`.

2. **Cross-session rejection** — `peekSessionFor(requestId)` resolves the session the request actually belongs to. If it belongs to a _different_ session, the vote is rejected (returns `false` / 404) without exposing session-membership information.

3. **Unknown-request guard** — when `peekSessionFor` returns `undefined` (request timed out, LRU-evicted, or never existed), the vote is rejected (returns `false` / 404) **before** any `clientId` validation. This prevents an oracle attack: without it, a probe with a fabricated `clientId` could distinguish "session has this client" (passes validation → 404) from "client unknown" (`InvalidClientIdError` → 400).

4. **Client identity validation** — `resolveTrustedClientId(entry, context?.clientId)` verifies the supplied `X-Qwen-Client-Id` (REST) or bridge-stamped `clientId` (ACP) is registered on the session's `clientIds` map. Anonymous votes (`clientId === undefined`) pass through — policy dispatch handles them. Unregistered ids throw `InvalidClientIdError` (mapped to 400 by route handlers).

5. **Cancel sentinel enforcement** — a wire vote of `{ outcome: "selected", optionId: "__cancelled__" }` is rejected with `InvalidPermissionOptionError` to prevent sentinel injection.

6. **Mediator `vote()` dispatch** — the validated vote is forwarded to `permissionMediator.vote(...)` which applies the active policy (see [Workflow → `vote()` dispatch](#vote-dispatch)).

### Loopback evaluation

The `fromLoopback` bit is evaluated **per request**, not per connection:

- **ACP transport**: `reqLoopback` is stamped from the POST request's kernel-level `req.socket.remoteAddress` at the HTTP layer and passed to `dispatcher.handle(conn, msg, sessionHeader, isLoopbackReq(req))`. This means a permission-vote POST arriving from a different peer than the `initialize` request gets its own loopback assessment.
- **REST API**: `detectFromLoopback(req)` evaluates the same socket-level remote address.

Neither path derives loopback from forgeable headers (`X-Forwarded-For`, `Forwarded`, etc.).

### ACP transport vote response format

A client responds to `session/request_permission` with a standard JSON-RPC response:

**Accept (select an option)**:

```json
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"result": {
"outcome": { "outcome": "selected", "optionId": "allow" }
}
}
```

**Cancel**:

```json
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"result": {
"outcome": { "outcome": "cancelled" }
}
}
```

**Error response** (mapped to cancel by the dispatcher):

```json
{
"jsonrpc": "2.0",
"id": "_qwen_perm_3",
"error": { "code": -32000, "message": "user declined" }
}
```

### Failure recovery in `resolveClientResponse`

When `bridge.respondToSessionPermission` throws (e.g. malformed vote body), the dispatcher falls back to an explicit cancel (`cancelAbandonedPermission`) so the mediator is never left permanently stuck. If both the vote and the cancel throw (double-failure), the `pending` entry is **retained** so the connection's eventual teardown (`abandonPendingForSession`) can retry.

## Caveats & Known Limits

- **Cancel sentinel routes BEFORE policy dispatch** by design — a `local-only` daemon and a `consensus` daemon can both be cancelled by any voter who posts `{outcome: 'cancelled'}`. This is documented at `permissionMediator.ts` and is the agent-side abort path.
Expand All @@ -269,6 +372,10 @@ mechanism does not exist in v1.
- `packages/acp-bridge/src/permission.ts` (frozen contract)
- `packages/acp-bridge/src/permissionMediator.ts` (F3 mediator implementation)
- `packages/acp-bridge/src/bridgeClient.ts` (uses structural sub-typing on `PermissionMediator`)
- `packages/acp-bridge/src/bridgeErrors.ts` (`CancelSentinelCollisionError`, `InvalidPermissionOptionError`, `PermissionForbiddenError`)
- `packages/acp-bridge/src/bridge.ts` (`respondToSessionPermission` — vote routing and authorization)
- `packages/acp-bridge/src/bridgeErrors.ts` (`CancelSentinelCollisionError`, `InvalidPermissionOptionError`, `PermissionForbiddenError`, `InvalidClientIdError`)
- `packages/cli/src/serve/acp-http/dispatch.ts` (`resolveClientResponse` — ACP transport vote path)
- `packages/cli/src/serve/acp-http/connection-registry.ts` (`AcpConnection.pending` — connection-local request mapping)
- `packages/cli/src/serve/routes/permission.ts` (REST vote routes)
- `packages/cli/src/serve/permission-audit.ts` (audit ring + publisher)
- Issue: [#4175](https://github.com/QwenLM/qwen-code/issues/4175) F3 series.
Loading
Loading