fix(daemon): resolve ACP permission votes across connections (#5912)

* fix(daemon): resolve acp permission votes across connections

* fix(daemon): address review on acp permission vote resolution

Resolve the review feedback on PR #5912:

- dispatch.ts session/permission: add server-side stderr logging to every
  failure mode (missing requestId, no pending entry, ownership failure,
  bridge rejection) so a stuck permission prompt is debuggable, matching the
  legacy resolveClientResponse path.
- On bridge rejection (accepted === false), stop deleting the pending entry
  and stop reusing the "no pending" error. Keep the entry until teardown (as
  the legacy path does) and return a distinct 409 "vote not accepted" error,
  so the two states aren't conflated and a retry on another connection can
  still land.
- connection-registry.ts: extract findPendingPermissionEntry shared by
  findPendingPermission and deletePendingPermission so the matching predicate
  lives in one place; delete now stops at the first (globally unique) match.
- index.ts: the abandonPending callback logs-and-returns-false before the
  dispatcher is initialized instead of throwing through the teardown path,
  matching the detachClient callback's defensive posture.
- Tests: cover the previously-untested handler branches (missing requestId,
  invalid outcome shapes, cancelled outcome, bridge rejection + sessionId
  inference + entry retention) and assert the connection-qualified id format
  and the undefined-sessionId lookup branch.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): log dropped cross-connection vote + cover handler error branches

Address the second ci-bot review round on PR #5912:

- dispatch.ts resolveClientResponse: the cross-connection ownership guard
  dropped a vote silently. Add a writeStderrLine so a vote rejected on the
  legacy path leaves the same grep-friendly operator signal the
  session/permission handler already emits — otherwise the agent's prompt
  stays blocked until teardown with no log to correlate.
- transport.test.ts: add end-to-end coverage for two previously-untested
  handler branches — the no-pending 404 response (requestId misses the
  registry with no sessionId) and the unowned-session rejection (a
  connection voting on a session it does not own).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(daemon): harden acp permission lookup and align registry api

Address the third ci-bot review round on PR #5912 (all non-blocking
suggestions):

- connection-registry.ts: collapse the redundant private
  findPendingPermissionEntry pass-through into the public
  findPendingPermission, and align deletePendingPermission to the same
  (requestId, sessionId) argument order so the two can never be called with
  swapped string args (a swap would silently match nothing and leak the
  entry until teardown, with no type error).
- dispatch.ts session/permission: look the pending entry up by the
  globally-unique requestId alone and treat the entry's own session as
  authoritative; when the client supplies a sessionId that does not match,
  reject with an explicit 409 instead of routing requireOwned and the bridge
  vote at the wrong session (which left the real entry to leak until
  teardown).
- Tests: add the sessionId-mismatch rejection case and update call sites for
  the new argument order.

Out of scope and deferred: making the dispatcher's registry a required
constructor parameter (and the dependent dropResolvedPermission cleanup) —
that changes the constructor contract beyond this fix.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): delete resolved permission by precise map key, fix comment

Address wenshao's review on PR #5912: findPendingPermission matches on
bridgeRequestId (a per-request randomUUID), not the connection-qualified
conn.pending map key. Under multi-client attach a permission_request reaches
every co-owning connection, each minting its own entry that shares the same
bridgeRequestId — so more than one entry can match and the prior "globally
unique, at most one match" comment was wrong.

- connection-registry.ts: correct the findPendingPermission doc to attribute
  uniqueness to the map key (not matched here) and note co-owning connections
  can share a bridgeRequestId, so callers needing a specific entry must act on
  the conn/map-key they already hold.
- dispatch.ts dropResolvedPermission: delete the resolved entry by its exact
  conn/map-key instead of re-matching by bridgeRequestId, which under
  multi-attach could delete a sibling connection's entry and orphan the one
  just resolved. Drops the now-unused req parameter.

deletePendingPermission stays for the session/permission handler, where the
lookup and delete consistently target the same first match.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): align acp permission errors with REST 404/400/403 shapes

Address wenshao's two [Critical] review findings on PR #5912:

- session/permission no longer falls through to the bridge when the registry
  misses. In the scoped route a sessionId is always supplied, so a stale/
  unknown requestId previously routed to the caller's session, got a bridge
  `false`, and was reported as a thrown 409 — diverging from the established
  `404 -> false` contract of DaemonClient.respondToSessionPermission() and the
  REST route. Now a registry miss returns 404; 409 is reserved for a present
  entry the bridge still rejects.
- Wrap the bridge vote and map permission-specific throws like REST's
  sendPermissionVoteError: InvalidPermissionOptionError -> INVALID_PARAMS with
  httpStatus 400 + invalid_option_id, PermissionForbiddenError -> httpStatus
  403 + permission_forbidden (with requestId/sessionId/reason). Previously
  these fell through the outer catch into a generic httpStatus-less internal
  error, so SDK callers saw 500s for normal permission outcomes. Import the
  error classes from acp-session-bridge (as REST does) so instanceof matches
  the class the bridge throws.
- Tests: cover the 404-on-miss-with-sessionId regression and the 400/403
  mappings.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(daemon): unify permission delete, whitelist vote forward

Address the fourth ci-bot review round on PR #5912 (non-blocking
suggestions):

- session/permission success path now drops the resolved entry through the
  shared dropResolvedPermission helper using the conn/map-key pendingRef
  already carries, instead of re-matching by requestId. Unifies the two
  delete sites and keeps the deletion precise.
- parsePermissionResponse forwards only the bridge-contract fields (outcome
  plus the ACP-reserved _meta passthrough) rather than copying every
  remaining client key, removing a needless client-controlled surface on the
  server-side bridge argument.
- transport.test.ts: the cross-connection permission test now asserts a
  duplicate vote on the same id does not reach the bridge again, locking down
  the cleanup guarantee that is the core of this PR.

Declined (replied on the threads): a blanket local try/catch around the
bridge vote (would shadow the outer dispatcher's typed-error mapping for
non-permission errors) and success-side audit logging in the generic
findPendingClientRequest (log noise / out of scope).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): full REST parity for acp permission vote errors

Address the fifth ci-bot review round on PR #5912:

- session/permission now maps every permission-specific bridge throw like
  REST's sendPermissionVoteError: InvalidPermissionOptionError -> 400,
  PermissionForbiddenError -> 403, PermissionPolicyNotImplementedError -> 501
  (policy), CancelSentinelCollisionError -> 500 (requestId/sentinel). The last
  two previously fell through to the outer dispatcher catch and became a
  generic -32603 without structured metadata.
- Truly unexpected bridge/sessionCtx failures now run the same
  cancelAbandonedPermission fallback as the legacy resolveClientResponse path
  (dropping the entry only if the cancel landed, else keeping it for teardown)
  before rethrowing — so an unexpected error no longer leaves the mediator
  blocking the agent's prompt until session teardown.
- parsePermissionResponse rebuilds the outcome sub-object from its validated
  keys instead of forwarding it verbatim, so a client can no longer inject
  extra outcome sub-fields (e.g. force) into the bridge argument; _meta is
  forwarded only when it is an object.
- Tests: cross-connection vote via the session/permission method (ack on the
  voter's stream + entry removed from the originator), plus the 501 and 500
  error mappings.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): preserve answers payload and delete only the voter's own entry

Address wenshao's two [Critical] findings on PR #5912:

- parsePermissionResponse no longer drops the AskUserQuestion `answers`
  payload. The whitelist tightening forwarded only outcome/_meta, but the
  bridge treats `answers` (an object map of string values) as the one
  supported non-ACP permission-response field, so votes were resolving while
  the agent received no submitted answers. Forward it under the same shape the
  bridge validates.
- The session/permission success path now deletes only the voting
  connection's OWN pending entry for the requestId, not the first
  registry-wide match. pendingRef can belong to a sibling connection; under
  the consensus policy respondToSessionPermission returns true for an
  intermediate "recorded" vote, so deleting a sibling's entry could drop a
  co-owner's still-needed request and stall the quorum. A cross-connection
  voter with no own entry deletes nothing and leaves the originator's entry
  for teardown.
- Tests: forward-answers/strip-unknown-fields case, and the cross-connection
  method test now asserts a co-owner's vote does NOT delete the originator's
  sibling entry.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): validate legacy vote path and scope permission deletes to voter

Address the sixth ci-bot review round on PR #5912 (2 Critical + 4 suggestions):

- resolveClientResponse now validates/whitelists the client result through the
  same parsePermissionResponse the session/permission handler uses. This PR
  had widened that legacy path to any co-owning connection (via
  findPendingClientRequest), so the raw `result as unknown` cast was a
  cross-connection injection surface for arbitrary top-level args and extra
  outcome sub-fields; a malformed result still throws and hits the cancel
  fallback as before.
- The unexpected-error cancel fallback in the session/permission handler now
  drops only the VOTING connection's own entry (via the new shared
  dropOwnPendingPermission helper), not pendingRef — which is the first
  registry-wide match and may be the originator's entry, whose deletion would
  stall a consensus quorum still awaiting other co-owners.
- parsePermissionResponse logs a stderr line when a present-but-malformed
  `answers` is dropped, instead of silently discarding it.
- Removed ConnectionRegistry.deletePendingPermission: it had no production
  callers and its first-match semantics were unsafe under co-owned sessions
  (deletion is done connection-scoped in the dispatcher).
- Tests: _meta object-preserved / non-object-dropped, and the generic
  unexpected-error fallthrough (cancel fallback runs + error propagates).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): structured 403 for unowned session/permission vote

Address wenshao's remaining [Critical] on PR #5912: the session/permission
ownership rejection went through the shared requireOwned, which sends an
INVALID_PARAMS error with no `data` envelope. Every other error path in this
handler carries `{ httpStatus }` (404/409/400/403/500/501), so SDK callers
that classify permission-vote failures by error.data.httpStatus got undefined
for the likeliest cross-connection failure (right session header, no
session/new on this connection). Inline the ownership check so the rejection
carries httpStatus 403 + sessionId + requestId, leaving the shared requireOwned
untouched for other handlers. Test asserts the 403.

(wenshao's other two criticals — legacy raw-result forwarding and the
catch-all deleting the originator's entry — were already fixed in 4bb06e5fd.)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): structured 400 for invalid session/permission params

Address the latest ci-bot suggestion on PR #5912: parsePermissionResponse
throws AcpParamError, a plain Error with no httpStatus, which the outer
dispatcher catch maps to a bare INVALID_PARAMS — inconsistent with every other
error path in this handler (404/409/400/403/500/501 all carry httpStatus).
Catch AcpParamError locally and return a structured 400 with requestId, so SDK
callers that classify by error.data.httpStatus see a consistent shape. The
parametrized invalid-outcome test now asserts the 400.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* perf(daemon): O(1) pending lookup, log vote success, cover malformed answers

Address wenshao's three suggestions on PR #5912:

- findPendingClientRequest parses the originating connectionId from the
  server-minted id format (_qwen_perm_<connectionId>_<counter>) for an O(1)
  byId lookup, falling back to the full scan for client-chosen ids.
- The session/permission success path now writes a stderr line ("vote
  accepted") so an operator debugging a stuck prompt can tell it apart from
  "vote never arrived" or "landed on another connection" — every failure
  branch already logs.
- Added a test for the malformed-answers branch (non-string values) asserting
  the vote still lands but answers are not forwarded to the bridge.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): httpStatus on missing-requestId + cross-conn malformed vote test

Address the latest ci-bot review on PR #5912:

- The missing-`requestId` rejection in session/permission was the only error
  branch without an { httpStatus } envelope. Add httpStatus 400 (+ requestId)
  so SDK callers can classify it like every other validation error here. Test
  asserts the 400.
- Add an integration test for the legacy resolveClientResponse cross-connection
  variant: connection B (a co-owner) answers connection A's permission request
  with a malformed result, parsePermissionResponse (added for this path) throws,
  and the cancel fallback still releases the mediator.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ChiGao 2026-07-01 06:43:52 +08:00 committed by GitHub
parent 7b9e31885b
commit c48dc32b57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1812 additions and 29 deletions

View file

@ -859,4 +859,49 @@ describe('ConnectionRegistry.getSnapshot', () => {
registry.dispose();
}
});
it('finds pending permissions across connections', () => {
const registry = new ConnectionRegistry();
try {
const connA = registry.create(false);
const connB = registry.create(false);
expect(connA).toBeDefined();
expect(connB).toBeDefined();
if (!connA || !connB) return;
const idA = connA.nextId();
const idB = connB.nextId();
expect(idA).not.toBe(idB);
// Pin the connection-qualified format `_qwen_perm_<connectionId>_<N>` —
// it's the collision-prevention guarantee of this change, so a
// regression to the old `_qwen_perm_<N>` format must fail here, not just
// an "ids differ" check that the old format would also pass.
expect(idA).toMatch(/^_qwen_perm_.+_1$/);
expect(idA).toContain(connA.connectionId);
expect(idB).toContain(connB.connectionId);
connA.pending.set(idA, {
sessionId: 'sess-1',
bridgeRequestId: 'perm-1',
kind: 'permission',
});
expect(registry.findPendingClientRequest(idA)?.conn).toBe(connA);
expect(registry.findPendingPermission('perm-1', 'sess-1')?.id).toBe(idA);
// The `sessionId === undefined` branch (relied on by dispatch's
// `session/permission` handler when no sessionId is supplied) matches on
// requestId alone, while a mismatched sessionId must not match.
expect(registry.findPendingPermission('perm-1')?.id).toBe(idA);
expect(
registry.findPendingPermission('perm-1', 'wrong-session'),
).toBeUndefined();
// findPendingPermission is a read-only locator; deletion is done by the
// owning connection on its own map key (AcpDispatcher.dropOwnPendingPermission).
connA.pending.delete(idA);
expect(registry.findPendingClientRequest(idA)).toBeUndefined();
} finally {
registry.dispose();
}
});
});

View file

@ -145,6 +145,12 @@ export interface PendingClientRequest {
kind: 'permission';
}
export interface PendingClientRequestRef {
conn: AcpConnection;
id: string;
req: PendingClientRequest;
}
export interface AcpConnectionDiagnostic {
connectionIdPrefix: string;
fromLoopback: boolean;
@ -254,13 +260,13 @@ export class AcpConnection {
/**
* Allocate a fresh JSON-RPC id for an agentclient request. STRING-typed
* (`_qwen_perm_N`) so it can never collide with a client-originated id
* (`_qwen_perm_<conn>_N`) so it can never collide with a client-originated id
* JSON-RPC 2.0 permits clients to use any number (incl. negatives) or
* string, so a numeric namespace wasn't actually safe.
*/
nextId(): string {
this.idCounter += 1;
return `_qwen_perm_${this.idCounter}`;
return `_qwen_perm_${this.connectionId}_${this.idCounter}`;
}
touch(): void {
@ -901,6 +907,64 @@ export class ConnectionRegistry {
return conn;
}
findPendingClientRequest(id: string): PendingClientRequestRef | undefined {
// Fast path: server-minted ids embed their originating connection
// (`_qwen_perm_<connectionId>_<counter>`, connectionId is a hyphenated
// `randomUUID()` with no underscores), so the owning connection is the
// substring before the last `_` — an O(1) lookup instead of scanning all
// connections on every client response.
if (id.startsWith('_qwen_perm_')) {
const body = id.slice('_qwen_perm_'.length);
const lastUnderscore = body.lastIndexOf('_');
if (lastUnderscore > 0) {
const conn = this.byId.get(body.slice(0, lastUnderscore));
const req = conn?.pending.get(id);
if (conn && req) return { conn, id, req };
}
}
// Fallback for client-chosen ids that don't match the server format.
for (const conn of this.byId.values()) {
const req = conn.pending.get(id);
if (req) return { conn, id, req };
}
return undefined;
}
/**
* Locate a pending permission entry matching `requestId` (a bridge
* `bridgeRequestId`, i.e. a per-request `randomUUID()`) and optionally
* `sessionId`, returning the first match.
*
* NOTE: `requestId` is unique per *request*, not per *pending entry*. The
* per-entry unique id is the `conn.pending` map key
* (`_qwen_perm_<connectionId>_N`), which is NOT what is matched here. A
* `permission_request` is delivered to every live subscriber of its session,
* so when connections co-own a session (multi-client attach) each mints its
* own entry sharing the same `bridgeRequestId`. More than one entry can
* therefore match, so this is a *read-only locator* for deriving a session /
* ownership from a wire `requestId`. To DELETE a resolved entry, act on the
* specific `conn`/map-key the caller already holds (see
* `AcpDispatcher.dropOwnPendingPermission`) never delete by re-matching
* here, which could hit a sibling co-owner's entry.
*/
findPendingPermission(
requestId: string,
sessionId?: string,
): PendingClientRequestRef | undefined {
for (const conn of this.byId.values()) {
for (const [id, req] of conn.pending) {
if (
req.kind === 'permission' &&
req.bridgeRequestId === requestId &&
(sessionId === undefined || req.sessionId === sessionId)
) {
return { conn, id, req };
}
}
}
return undefined;
}
delete(connectionId: string): boolean {
const conn = this.byId.get(connectionId);
if (!conn) return false;

View file

@ -17,6 +17,15 @@ import {
writeWorkspaceContextFile,
type SubagentLevel,
} from '@qwen-code/qwen-code-core';
// Import the permission error classes from the same module REST's
// `sendPermissionVoteError` uses, so `instanceof` matches the class the bridge
// actually throws (the core re-export is a distinct identity at runtime).
import {
CancelSentinelCollisionError,
InvalidPermissionOptionError,
PermissionForbiddenError,
PermissionPolicyNotImplementedError,
} from '../acp-session-bridge.js';
import { FsError } from '../fs/errors.js';
import {
TooManyActiveDeviceFlowsError,
@ -72,7 +81,11 @@ import {
WorkspacePermissionRulesSessionRequiredError,
WorkspaceSettingsPartialPersistError,
} from '../workspace-service/types.js';
import type { AcpConnection } from './connection-registry.js';
import type {
AcpConnection,
ConnectionRegistry,
PendingClientRequestRef,
} from './connection-registry.js';
import {
QWEN_META_KEY,
QWEN_METHOD_NS,
@ -96,7 +109,13 @@ function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
type PermissionResponse = Parameters<
HttpAcpBridge['respondToSessionPermission']
>[2];
const SESSION_SHELL_METHOD = `${QWEN_METHOD_NS}session/shell`;
const INVALID_PERMISSION_OUTCOME_ERROR =
'`outcome` must be `{ outcome: "cancelled" }` or `{ outcome: "selected", optionId: string }`';
const ALL_QWEN_VENDOR_METHODS: readonly string[] = [
`${QWEN_METHOD_NS}session/heartbeat`,
@ -178,6 +197,7 @@ const CONN_ROUTED_METHODS = new Set<string>([
'session/list',
'session/close',
'session/fork',
'session/permission',
...ALL_QWEN_VENDOR_METHODS,
]);
@ -275,6 +295,61 @@ function validatePrompt(params: Record<string, unknown>): void {
}
}
function parsePermissionResponse(
params: Record<string, unknown>,
): PermissionResponse {
const outcome = params['outcome'];
if (!isObject(outcome)) {
throw new AcpParamError(INVALID_PERMISSION_OUTCOME_ERROR);
}
if (outcome['outcome'] !== 'cancelled') {
if (
outcome['outcome'] !== 'selected' ||
typeof outcome['optionId'] !== 'string' ||
outcome['optionId'].length === 0
) {
throw new AcpParamError(INVALID_PERMISSION_OUTCOME_ERROR);
}
}
// Whitelist only the fields the bridge contract defines — a rebuilt `outcome`
// carrying just its validated keys, plus the ACP-reserved `_meta` passthrough
// — rather than forwarding client-supplied keys. The previous copy-all let a
// client inject arbitrary top-level args AND extra `outcome` sub-fields (e.g.
// `{ outcome: { outcome: 'selected', optionId: 'allow', force: true } }`) into
// the server-side bridge call; harmless today since the bridge reads only the
// discriminant + optionId, but a needless client-controlled surface.
const cleanOutcome =
outcome['outcome'] === 'cancelled'
? { outcome: 'cancelled' as const }
: { outcome: 'selected' as const, optionId: outcome['optionId'] };
const response: Record<string, unknown> = { outcome: cleanOutcome };
// `answers` is the one non-ACP permission-response field the bridge honours
// (AskUserQuestion). Forward it under the same shape the bridge validates —
// an object map of string values — so dropping it doesn't silently leave the
// agent with no submitted answers.
const answers = params['answers'];
if (answers !== undefined) {
if (
isObject(answers) &&
Object.values(answers).every((value) => typeof value === 'string')
) {
response['answers'] = answers;
} else {
// Present but malformed: the bridge would reject it anyway, so it is
// dropped — but log it, otherwise a client whose answers silently
// vanished (agent sees none) has no server-side signal to chase.
writeStderrLine(
'qwen serve: /acp session/permission dropping invalid `answers` (expected an object map of string values)',
);
}
}
if (isObject(params['_meta'])) {
response['_meta'] = params['_meta'];
}
return response as PermissionResponse;
}
/**
* Map a thrown error to a JSON-RPC error code + a client-safe message.
* Param-validation errors are echoed (they describe the client's own bad
@ -431,6 +506,7 @@ export class AcpDispatcher {
private readonly fsFactory?: WorkspaceFileSystemFactory,
private readonly deviceFlowRegistry?: DeviceFlowRegistry,
private readonly sessionShellCommandEnabled: boolean = false,
private readonly registry?: ConnectionRegistry,
) {
this.agentManager = createDaemonSubagentManager(boundWorkspace);
}
@ -673,6 +749,44 @@ export class AcpDispatcher {
return false;
}
private findPendingClientRequest(
conn: AcpConnection,
id: string,
): PendingClientRequestRef | undefined {
const req = conn.pending.get(id);
if (req) return { conn, id, req };
return this.registry?.findPendingClientRequest(id);
}
private dropResolvedPermission(conn: AcpConnection, id: string): void {
// Delete the exact resolved entry by its `conn.pending` map key. Under
// multi-client attach, sibling connections can hold their own pending
// entries sharing this one's `bridgeRequestId` (see
// ConnectionRegistry.findPendingPermission), so re-matching by
// `bridgeRequestId` could delete a sibling's entry and orphan the one we
// just resolved. The siblings' now-moot entries are reaped at teardown.
conn.pending.delete(id);
}
/**
* Drop ONLY the calling connection's own pending permission entry for
* `requestId`, never a sibling co-owner's. Under the consensus policy a vote
* (or an unexpected vote error) from connection B must not delete connection
* A's still-needed entry, which would stall the quorum. A connection that
* never streamed the request holds no entry, so this is a no-op for it.
*/
private dropOwnPendingPermission(
conn: AcpConnection,
requestId: string,
): void {
for (const [pid, preq] of conn.pending) {
if (preq.kind === 'permission' && preq.bridgeRequestId === requestId) {
conn.pending.delete(pid);
return;
}
}
}
/**
* Handle one inbound POST message. Returns nothing every reply is
* delivered asynchronously on a long-lived SSE stream per the RFD
@ -1005,6 +1119,297 @@ export class AcpDispatcher {
return;
}
case 'session/permission': {
const requestId =
typeof params['requestId'] === 'string' ? params['requestId'] : '';
if (!requestId) {
// Every failure mode below logs to stderr: a stuck permission
// prompt is otherwise undebuggable from the server side (the
// legacy `resolveClientResponse` path logs its vote/cancel
// failures the same way).
writeStderrLine(
`qwen serve: /acp session/permission rejected: \`requestId\` is required`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`requestId` is required', {
httpStatus: 400,
requestId,
}),
);
}
return;
}
let response: PermissionResponse;
try {
response = parsePermissionResponse(params);
} catch (err) {
// Map the validation throw to a structured 400 here so it carries
// the same `{ httpStatus }` envelope as every other error path in
// this handler — the outer dispatcher catch would otherwise emit a
// plain INVALID_PARAMS with no httpStatus for SDK callers.
if (err instanceof AcpParamError) {
writeStderrLine(
`qwen serve: /acp session/permission invalid params (requestId ${logSafe(requestId)}): ${logSafe(err.message)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, err.message, {
httpStatus: 400,
requestId,
}),
);
}
return;
}
throw err;
}
const sessionIdParam =
typeof params['sessionId'] === 'string' &&
params['sessionId'].length > 0
? params['sessionId']
: undefined;
// Look up by the globally-unique `requestId` alone, then validate
// the client's `sessionId` against the pending entry instead of
// trusting it for routing. A mismatched `sessionId` previously fell
// through to `sessionIdParam`, which (a) routed `requireOwned` and
// the bridge vote at the wrong session and (b) left the real pending
// entry to leak until teardown. Reject the mismatch explicitly.
const pendingRef = this.registry?.findPendingPermission(requestId);
if (
sessionIdParam &&
pendingRef &&
sessionIdParam !== pendingRef.req.sessionId
) {
writeStderrLine(
`qwen serve: /acp session/permission vote rejected: requestId ${logSafe(requestId)} does not belong to session ${logSafe(sessionIdParam)}`,
);
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'requestId does not belong to the specified session',
{ httpStatus: 409, sessionId: sessionIdParam, requestId },
),
);
}
return;
}
// Require a registry hit before voting when the registry is
// available. In the scoped `session/permission` route `sessionIdParam`
// is always set, so a miss (unknown/stale/already-resolved request)
// must NOT fall through to `sessionIdParam` and the bridge — that
// reports the bridge's `false` as a thrown 409, whereas
// DaemonClient/REST treat a missing request as `404 → false` (not an
// exception). Reserve 409 for a present entry the bridge still rejects.
if (this.registry && !pendingRef) {
writeStderrLine(
`qwen serve: /acp session/permission vote dropped: no pending request for requestId ${logSafe(requestId)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, 'No pending permission request', {
httpStatus: 404,
requestId,
}),
);
}
return;
}
// The pending entry's own session is authoritative; fall back to the
// client's `sessionId` only when there is no registry to consult.
const sessionId = pendingRef?.req.sessionId ?? sessionIdParam;
if (!sessionId) {
writeStderrLine(
`qwen serve: /acp session/permission vote dropped: no pending request for requestId ${logSafe(requestId)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, 'No pending permission request', {
httpStatus: 404,
requestId,
}),
);
}
return;
}
// Inline ownership check (not the shared `requireOwned`) so the
// rejection carries the same `{ httpStatus }` envelope as every other
// error path in this handler — SDK callers classify permission-vote
// failures by `error.data.httpStatus`, and this is the likeliest
// cross-connection failure (right session header, no `session/new` on
// this connection).
if (!conn.ownsSession(sessionId)) {
writeStderrLine(
`qwen serve: /acp session/permission vote rejected: session ${logSafe(sessionId)} not owned by this connection (requestId ${logSafe(requestId)})`,
);
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'Session not owned by this connection',
{ httpStatus: 403, sessionId, requestId },
),
);
}
return;
}
let accepted: boolean;
try {
accepted = this.bridge.respondToSessionPermission(
sessionId,
requestId,
response,
this.sessionCtx(conn, sessionId, loopback),
);
} catch (err) {
// Mirror REST's `sendPermissionVoteError` so ACP SDK callers get the
// same shapes for normal permission outcomes instead of a generic
// internal error from the outer dispatcher catch: a forged optionId
// is a 400 (the requestId is known, the option isn't), a
// policy-denied vote is a 403 (well-formed, authenticated, refused).
if (err instanceof InvalidPermissionOptionError) {
writeStderrLine(
`qwen serve: /acp session/permission invalid option (${logSafe(sessionId)}, requestId ${logSafe(requestId)}): ${logSafe(err.message)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, err.message, {
httpStatus: 400,
code: 'invalid_option_id',
requestId: err.requestId,
optionId: err.optionId,
}),
);
}
return;
}
if (err instanceof PermissionForbiddenError) {
writeStderrLine(
`qwen serve: /acp session/permission forbidden (${logSafe(sessionId)}, requestId ${logSafe(requestId)}): ${logSafe(err.message)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, err.message, {
httpStatus: 403,
code: 'permission_forbidden',
requestId: err.requestId,
sessionId: err.sessionId,
reason: err.reason,
}),
);
}
return;
}
if (err instanceof PermissionPolicyNotImplementedError) {
// Operator's settings name a policy whose mediator hasn't landed
// in this build. 501 (not 500) so the SDK can say "daemon older
// than your settings expect; upgrade".
writeStderrLine(
`qwen serve: /acp session/permission policy not implemented (${logSafe(sessionId)}, requestId ${logSafe(requestId)}): ${logSafe(err.message)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INTERNAL_ERROR, err.message, {
httpStatus: 501,
code: 'permission_policy_not_implemented',
policy: err.policy,
}),
);
}
return;
}
if (err instanceof CancelSentinelCollisionError) {
// Agent/daemon contract violation (agent's option set includes
// the cancel sentinel), not a client mistake — 500 with a stable
// code so the SDK can distinguish it from unrelated internals.
writeStderrLine(
`qwen serve: /acp session/permission cancel-sentinel collision (${logSafe(sessionId)}, requestId ${logSafe(requestId)}): ${logSafe(err.message)}`,
);
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INTERNAL_ERROR, err.message, {
httpStatus: 500,
code: 'cancel_sentinel_collision',
requestId: err.requestId,
sentinel: err.sentinel,
}),
);
}
return;
}
// Truly unexpected bridge/sessionCtx failure: the mediator may be
// left blocking the agent's prompt. Mirror the legacy
// `resolveClientResponse` path — cancel as a fallback (dropping the
// entry only if the cancel landed, else keep it for teardown to
// retry) — then rethrow so the outer dispatcher catch maps the
// error for the wire.
writeStderrLine(
`qwen serve: /acp session/permission vote failed (${logSafe(sessionId)}, requestId ${logSafe(requestId)}): ${logSafe(errMsg(err))}`,
);
const cancelled = this.cancelAbandonedPermission(
{ sessionId, bridgeRequestId: requestId },
pendingRef?.conn.sessions.get(sessionId)?.clientId,
);
// Drop only the VOTING connection's own entry (consistent with the
// success path) — never `pendingRef`, which may be a sibling/the
// originator. Deleting the originator's entry would stall a quorum
// still waiting on its vote. If the voter has no own entry, leave
// everything for teardown.
if (cancelled) {
this.dropOwnPendingPermission(conn, requestId);
}
throw err;
}
if (!accepted) {
// The bridge mediator had no outstanding request to resolve
// (already voted/cancelled — e.g. a duplicate or racing vote).
// Do NOT delete the registry entry here: matching the legacy
// `resolveClientResponse` contract, keep it until teardown's
// `abandonPendingForSession` releases the mediator. Deleting now
// would (a) conflate "no pending entry" with "bridge rejected a
// present vote" and (b) make a legitimate retry on another
// connection fail with a misleading "no pending" error.
writeStderrLine(
`qwen serve: /acp session/permission vote not accepted by bridge (${logSafe(sessionId)}, requestId ${logSafe(requestId)})`,
);
if (id !== undefined) {
conn.sendConn(
error(
id,
RPC.INVALID_PARAMS,
'Permission vote not accepted (request already resolved)',
{
httpStatus: 409,
sessionId,
requestId,
},
),
);
}
return;
}
// Drop ONLY the voting connection's own pending entry for this
// request — never the first registry-wide match (`pendingRef`), which
// may belong to a sibling connection. Under the consensus policy
// `respondToSessionPermission` returns true for an intermediate
// "recorded" vote, so deleting a sibling's entry here would drop a
// co-owner's still-needed outbound request and could stall the quorum
// until timeout/teardown. A cross-connection voter that never streamed
// the request has no own entry — leave the originator's for teardown.
this.dropOwnPendingPermission(conn, requestId);
// Log the success too (every failure branch logs): an operator
// grepping a stuck prompt can then tell "vote accepted here" apart
// from "vote never arrived" or "vote landed on another connection".
writeStderrLine(
`qwen serve: /acp session/permission vote accepted (${logSafe(sessionId)}, requestId ${logSafe(requestId)}, connection ${logSafe(conn.connectionId.slice(0, 8))})`,
);
this.replyConn(conn, id, {});
return;
}
// STANDARD method (SDK 0.14.1, non-`unstable_`): model + mode live
// here under categories `model`/`mode`, routed to the existing bridge
// setters. Replaces the old vendor `_qwen/session/set_model`.
@ -3016,29 +3421,50 @@ export class AcpDispatcher {
msg: JsonRpcResponse,
fromLoopback: boolean,
): void {
// Our outbound request ids are strings (`_qwen_perm_N`); a client echoes
// Our outbound request ids are strings (`_qwen_perm_<conn>_N`); a client echoes
// the same id verbatim. Anything else can't match a pending entry.
const id = msg.id;
if (typeof id !== 'string') return;
const pending = conn.pending.get(id);
if (!pending) return;
const pendingRef = this.findPendingClientRequest(conn, id);
if (!pendingRef) return;
const pendingConn = pendingRef.conn;
const pending = pendingRef.req;
if (pendingConn !== conn && !conn.ownsSession(pending.sessionId)) {
// Mirror the `session/permission` handler: never drop a cross-connection
// vote silently. The POST already returned 202, so without a log line the
// operator has no grep-friendly signal to correlate against a permission
// prompt that stays blocked until teardown's `abandonPendingForSession`.
writeStderrLine(
`qwen serve: /acp permission vote dropped: responding connection ${logSafe(
conn.connectionId.slice(0, 8),
)} does not own session ${logSafe(pending.sessionId)} (requestId ${logSafe(
pending.bridgeRequestId,
)})`,
);
return;
}
// NOTE: do NOT delete the pending entry yet. Keep it until either the
// bridge vote OR the cancel fallback runs — if both somehow fail, the
// entry survives so a later session/connection teardown
// (`abandonPendingForSession`) can still release the mediator.
// A client error response is a cancellation; otherwise pass the result
// through. The cast defers shape validation to the bridge, so a
// MALFORMED result (e.g. `{}` with no `outcome`) makes the mediator
// throw — caught below, where we fall back to an explicit cancel so the
// mediator is always released. The pending entry is dropped only after a
// successful vote/cancel (see the NOTE above), so a double-failure leaves
// it for teardown to retry.
const vote =
'error' in msg
? { outcome: { outcome: 'cancelled' } }
: (msg as { result: unknown }).result;
try {
// A client error response is a cancellation; otherwise validate +
// whitelist the result through the SAME `parsePermissionResponse` the
// `session/permission` handler uses. This PR widened this path to any
// co-owning connection (via `findPendingClientRequest`), so without the
// shared validator a co-owner could inject arbitrary top-level args and
// extra `outcome` sub-fields straight to the bridge. A malformed result
// throws here (as the old direct cast made the bridge throw) and is
// caught below, where the cancel fallback always releases the mediator.
const vote =
'error' in msg
? { outcome: { outcome: 'cancelled' } }
: parsePermissionResponse(
isObject((msg as { result: unknown }).result)
? (msg as { result: Record<string, unknown> }).result
: {},
);
this.bridge.respondToSessionPermission(
pending.sessionId,
pending.bridgeRequestId,
@ -3047,7 +3473,7 @@ export class AcpDispatcher {
>[2],
this.sessionCtx(conn, pending.sessionId, fromLoopback),
);
conn.pending.delete(id); // vote landed — safe to drop
this.dropResolvedPermission(pendingConn, id);
} catch (err) {
writeStderrLine(
`qwen serve: /acp permission vote failed (${logSafe(pending.sessionId)}): ${logSafe(errMsg(err))}`,
@ -3058,9 +3484,9 @@ export class AcpDispatcher {
// permanently stuck with no recovery path.
const cancelled = this.cancelAbandonedPermission(
pending,
conn.sessions.get(pending.sessionId)?.clientId,
pendingConn.sessions.get(pending.sessionId)?.clientId,
);
if (cancelled) conn.pending.delete(id);
if (cancelled) this.dropResolvedPermission(pendingConn, id);
}
}

View file

@ -280,18 +280,24 @@ export function mountAcpHttp(
if (!enabled) return undefined;
const path = opts.path ?? '/acp';
const dispatcher = new AcpDispatcher(
bridge,
opts.boundWorkspace,
opts.workspace,
opts.fsFactory,
opts.deviceFlowRegistry,
opts.sessionShellCommandEnabled === true,
);
const dispatcherRef: { current?: AcpDispatcher } = {};
// When a session/connection tears down with a permission still pending,
// cancel it on the bridge so the agent's prompt isn't left blocked.
const registry = new ConnectionRegistry(
(req, clientId) => dispatcher.cancelAbandonedPermission(req, clientId),
(req, clientId) => {
// Defensive, matching the `detachClient` callback below: if a future
// refactor introduces async work between registry and dispatcher
// creation, a teardown racing in here must not crash
// `abandonPendingForSession`. Log and report "not cancelled" instead of
// throwing through the teardown path.
if (!dispatcherRef.current) {
writeStderrLine(
'qwen serve: /acp abandonPending called before dispatcher initialized (skipped)',
);
return false;
}
return dispatcherRef.current.cancelAbandonedPermission(req, clientId);
},
// Best-effort bridge detach so a torn-down connection's bridge-stamped
// client ids don't linger in the bridge's voter/known-client sets.
(sessionId, clientId) => {
@ -305,6 +311,16 @@ export function mountAcpHttp(
},
opts.maxConnections,
);
const dispatcher = new AcpDispatcher(
bridge,
opts.boundWorkspace,
opts.workspace,
opts.fsFactory,
opts.deviceFlowRegistry,
opts.sessionShellCommandEnabled === true,
registry,
);
dispatcherRef.current = dispatcher;
// ── POST /acp ──────────────────────────────────────────────────────
app.post(path, async (req: Request, res: Response) => {

File diff suppressed because it is too large Load diff