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
1,190 changes: 433 additions & 757 deletions packages/acp-bridge/src/bridge.ts

Large diffs are not rendered by default.

386 changes: 149 additions & 237 deletions packages/acp-bridge/src/bridgeClient.ts

Large diffs are not rendered by default.

34 changes: 14 additions & 20 deletions packages/acp-bridge/src/bridgeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
* "session limit reached, retry after N seconds") without parsing
* free-form text.
*
* Lifted from `packages/cli/src/serve/httpAcpBridge.ts` in #4175 PR
* 22b/1 so the bridge package owns the error contract directly. The
*
* The bridge package owns the error contract directly. The
* 7 error classes server.ts imports + 1 each from workspaceAgents.ts
* and workspaceMemory.ts continue to resolve through the
* httpAcpBridge.ts re-export shim.
Expand All @@ -40,14 +40,8 @@ export const NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE =
* propagate to callers.
*/
export function isNotCurrentlyGeneratingCancelError(err: unknown): boolean {
if (err instanceof Error && isNotCurrentlyGeneratingText(err.message)) {
return true;
}
if (!err || typeof err !== 'object') return false;
const maybe = err as {
message?: unknown;
data?: unknown;
};
const maybe = err as { message?: unknown; data?: unknown };
if (isNotCurrentlyGeneratingText(maybe.message)) return true;
if (!maybe.data || typeof maybe.data !== 'object') return false;
return isNotCurrentlyGeneratingText(
Expand Down Expand Up @@ -131,7 +125,7 @@ export class SessionLimitExceededError extends Error {

/**
* Thrown by `spawnOrAttach` when the requested `workspaceCwd` doesn't
* canonicalize to the daemon's bound workspace. Per #3803 §02 every
* canonicalize to the daemon's bound workspace. Every
* bridge instance is bound to exactly one workspace; cross-workspace
* requests are rejected at the daemon boundary. The server route
* translates this to a 400 response with `code: 'workspace_mismatch'`
Expand Down Expand Up @@ -208,11 +202,11 @@ export class InvalidSessionMetadataError extends Error {
}

/**
* #4175 F3. Thrown by `MultiClientPermissionMediator.vote` when the
* Typed error for unimplemented permission policies. Thrown by `MultiClientPermissionMediator.vote` when the
* active policy is wired into the schema/registry but the mediator
* implementation has not been built yet.
*
* **Currently unreachable in production** — F3 Commit 4 implemented
* **Currently unreachable in production** — the current code implements
* all 4 policies in the frozen `PermissionPolicy` union. The class +
* route-level 501 mapping in `server.ts:sendPermissionVoteError` are
* RETAINED as forward-compat infrastructure: when a future PR adds a
Expand All @@ -238,7 +232,7 @@ export class PermissionPolicyNotImplementedError extends Error {
}

/**
* #4175 F3 Commit 1. Thrown by `MultiClientPermissionMediator.request`
* Collision defense. Thrown by `MultiClientPermissionMediator.request`
* when an agent-declared `allowedOptionIds` set contains the
* cancel-vote sentinel string. The bridge maps voter cancel intent
* to that exact `optionId`; if the agent legitimately uses it as
Expand Down Expand Up @@ -266,7 +260,7 @@ export class CancelSentinelCollisionError extends Error {
}

/**
* #4175 F3 Commit 2. Thrown by `bridge.respondToSessionPermission` /
* Permission forbidden error. Thrown by `bridge.respondToSessionPermission` /
* `bridge.respondToPermission` when the active permission policy
* rejects the vote (designated voter mismatch, or remote vote under
* `local-only`). The bridge converts the mediator's
Expand Down Expand Up @@ -299,7 +293,7 @@ export class PermissionForbiddenError extends Error {
}

/**
* #4175 Wave 4 PR 17. Thrown by `initWorkspace` when the target file
* Workspace init conflict. Thrown by `initWorkspace` when the target file
* already exists with non-whitespace content and the caller did not
* pass `force: true`. Translated to HTTP 409 by the route. The
* `path` and `existingSize` fields let SDK clients render a clear
Expand All @@ -321,7 +315,7 @@ export class WorkspaceInitConflictError extends Error {
}

/**
* #4297 fold-in 1 (16:32:44-round S1). Thrown by `initWorkspace` when
* Path escape guard. Thrown by `initWorkspace` when
* the configured `context.fileName` resolves outside the bound
* workspace via path arithmetic (e.g. `../outside.md`). Translated
* to HTTP 400 by the route — distinguishable from a generic 500 so
Expand All @@ -345,7 +339,7 @@ export class WorkspaceInitPathEscapeError extends Error {
}

/**
* #4297 fold-in 1 (16:32:44-round S1). Thrown by `initWorkspace` when
* Path escape guard. Thrown by `initWorkspace` when
* the target file is itself a symlink, OR when the parent path
* canonicalizes (via `realpath`) outside the bound workspace.
* Translated to HTTP 400 by the route — same operator-clarity
Expand All @@ -365,7 +359,7 @@ export class WorkspaceInitSymlinkError extends Error {
}

/**
* #4297 fold-in 10 (qwen-latest, addresses #3263954690). Thrown by
* Race condition guard. Thrown by
* `initWorkspace` when the target file's inode misbehaved at write
* time IN A NON-SYMLINK WAY — typically a TOCTOU race against a
* concurrent writer:
Expand Down Expand Up @@ -393,7 +387,7 @@ export class WorkspaceInitRaceError extends Error {
}

/**
* #4282 fold-in 1 (gpt-5.5 C5). Thrown by `restartMcpServer` when the
* MCP server not found. Thrown by `restartMcpServer` when the
* caller asks for a server name that isn't in the daemon's
* `McpServers` config. Translated to HTTP 404 + structured body by
* the route — distinguishable from a generic 500 so a bad server
Expand All @@ -409,7 +403,7 @@ export class McpServerNotFoundError extends Error {
}

/**
* #4282 fold-in 1 (gpt-5.5 C4). Thrown by `restartMcpServer` when
* MCP restart failure. Thrown by `restartMcpServer` when
* `discoverMcpToolsForServer` resolves but the MCP client fails to
* end up `CONNECTED` post-discover. The manager catches reconnect
* errors and returns void, so without an explicit post-check the
Expand Down
35 changes: 15 additions & 20 deletions packages/acp-bridge/src/bridgeFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,18 @@ import type {

/**
* Injection seam for the ACP fs proxy on `BridgeClient.readTextFile` /
* `BridgeClient.writeTextFile`. The immediate follow-up PR will land
* a serve-side adapter that wraps PR 18's `WorkspaceFileSystem` so
* production `qwen serve` writes pick up the TOCTOU + symlink +
* trust-gate + audit machinery PR 18 introduced — closing the
* post-PR-18 follow-up thread about `BridgeClient`'s inline fs
* proxy bypassing `WorkspaceFileSystem` (originally raised in
* #4250 review; see also FIXME(stage-1.5, chiga0 finding 4) lifted
* to this package as part of #4175 F1). Until that adapter ships and `runQwenServe` wires it
* through `BridgeOptions.fileSystem`, BridgeClient continues to use
* its inline fs proxy (preserving pre-F1 behavior).
* `BridgeClient.writeTextFile`. A serve-side adapter wraps
* `WorkspaceFileSystem` so production `qwen serve` writes pick up the
* TOCTOU + symlink + trust-gate + audit machinery. Until that adapter
* ships and `runQwenServe` wires it through `BridgeOptions.fileSystem`,
* BridgeClient continues to use its inline fs proxy (preserving
* pre-extraction behavior).
*
* Lifted from the inline `fs.writeFile` / `fs.readFile` implementations
* BridgeClient carried before #4175 PR F1 (step 5, originally the
* 22b' scope). Bridge tests + Mode A embedded callers can omit the
* field on `BridgeOptions`; BridgeClient falls back to its inline
* proxy so the pre-lift behavior is preserved verbatim when no
* provider is injected.
* BridgeClient carried before the extraction. Bridge tests + Mode A
* embedded callers can omit the field on `BridgeOptions`; BridgeClient
* falls back to its inline proxy so the pre-lift behavior is preserved
* verbatim when no provider is injected.
*
* Method signatures intentionally mirror the ACP SDK request/response
* shapes so the adapter does the minimum amount of translation
Expand Down Expand Up @@ -77,8 +72,8 @@ export interface BridgeFileSystem {
* surface `symlink_escape`. This is a **divergence from the
* pre-F1 inline `BridgeClient.writeTextFile` proxy** which
* resolved symlinks and wrote through to their target;
* production now matches the more conservative PR 18 +
* HTTP `POST /file` posture (PR 20). Agents that previously
* production now matches the more conservative
* HTTP `POST /file` posture. Agents that previously
* relied on writing through symlinked dotfiles will need
* to address the resolved path directly.
* - **Workspace boundary enforcement** — paths outside the
Expand All @@ -89,9 +84,9 @@ export interface BridgeFileSystem {
* lacks the concept entirely). The contract does NOT require it.
*
* The serve-side adapter satisfies this via
* `WorkspaceFileSystem.writeTextOverwrite` — the PR 18 primitive
* that does atomic tmp+rename with mode preservation + `0o600`
* default + symlink reject inside a per-path lock.
* `WorkspaceFileSystem.writeTextOverwrite`, which does atomic
* tmp+rename with mode preservation + `0o600` default + symlink
* reject inside a per-path lock.
*/
writeText(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
}
37 changes: 18 additions & 19 deletions packages/acp-bridge/src/bridgeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

/**
* `BridgeOptions` and the daemon-host injection seam (`DaemonStatusProvider`)
* for the ACP bridge factory. Lifted to `@qwen-code/acp-bridge` in #4175 PR
* 22b/2 so the bridge package owns the construction contract independently
* of `cli/src/serve/`. The factory implementation itself moves in PR 22b/3.
* for the ACP bridge factory. Lifted to `@qwen-code/acp-bridge` so the
* bridge package owns the construction contract independently of
* `cli/src/serve/`.
*/

import type { ApprovalMode } from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -116,7 +116,7 @@ export interface BridgeTelemetry {
*/
export interface BridgeOptions {
/**
* §03 decision §1. `single` shares one session per workspace across HTTP
* `single` shares one session per workspace across HTTP
* clients (live-collaboration default); `thread` gives each `spawnOrAttach`
* call its own session for strict isolation.
*
Expand Down Expand Up @@ -145,7 +145,7 @@ export interface BridgeOptions {
* Per-session SSE replay ring depth. Sets `ringSize` on every
* `new EventBus(...)` the bridge constructs (both fresh sessions
* and restored sessions). Defaults to `DEFAULT_RING_SIZE` (8000,
* #3803 §02 target). Must be a positive finite integer; `0` /
* the daemon design target). Must be a positive finite integer; `0` /
* `NaN` / negative throw at boot (fail-CLOSED — same posture as
* `maxSessions`, where silently disabling a backpressure knob on a
* config typo is worse than failing to start).
Expand Down Expand Up @@ -175,7 +175,7 @@ export interface BridgeOptions {
maxPendingPermissionsPerSession?: number;
/**
* Absolute, **already-canonical** path this daemon is bound to (per
* #3803 §02: 1 daemon = 1 workspace). `spawnOrAttach` calls whose
* 1 daemon = 1 workspace). `spawnOrAttach` calls whose
* `workspaceCwd` doesn't canonicalize to this same value throw
* `WorkspaceMismatchError` (route → 400 with code `workspace_mismatch`).
*
Expand Down Expand Up @@ -217,7 +217,7 @@ export interface BridgeOptions {
*/
childEnvOverrides?: Readonly<Record<string, string | undefined>>;
/**
* #4175 Wave 4 PR 17 — optional callback for persisting `tools.
* -- optional callback for persisting `tools.
* approvalMode` to the workspace settings file. Invoked by
* `setSessionApprovalMode` ONLY when the route caller passes
* `{persist: true}`. The default `runQwenServe` wires this to
Expand All @@ -232,7 +232,7 @@ export interface BridgeOptions {
mode: ApprovalMode,
) => Promise<void>;
/**
* #4175 Wave 4 PR 17 — optional callback for mutating
* -- optional callback for mutating
* `tools.disabled` in workspace settings. Invoked by
* `setWorkspaceToolEnabled` to add (`enabled: false`) or remove
* (`enabled: true`) `toolName` from the persisted disabled set.
Expand All @@ -249,7 +249,7 @@ export interface BridgeOptions {
enabled: boolean,
) => Promise<void>;
/**
* #4282 fold-in 5 (Codex P2-1). Optional override for the basename
* Optional override for the basename
* (or single relative path) of the workspace context file written
* by `POST /workspace/init`. When omitted, falls back to
* `getCurrentGeminiMdFilename()` — the process-global value, which
Expand All @@ -261,7 +261,7 @@ export interface BridgeOptions {
*/
contextFilename?: string;
/**
* #4175 Wave 5 PR 22b/2 — optional injection seam for daemon-host
* -- optional injection seam for daemon-host
* status cells (env snapshot, daemon preflight). Production
* `qwen serve` provides
* `createDaemonStatusProvider()` from
Expand All @@ -272,7 +272,7 @@ export interface BridgeOptions {
* and `acpChannelLive` from bridge state) and an empty array for
* the daemon half of `getWorkspacePreflightStatus` (the ACP-level
* cells are still fetched normally when a child is live). This
* matches the "idle status is queryable" pattern PR 12 / 13
* matches the "idle status is queryable" pattern previous work
* established for diagnostic routes — direct embeds and tests
* that don't need daemon-host cells can omit the provider
* without crashing those routes.
Expand All @@ -287,19 +287,18 @@ export interface BridgeOptions {
telemetry?: BridgeTelemetry;

/**
* Optional fs injection seam (#4175 PR F1 step 5, originally the
* 22b' scope). When provided, `BridgeClient.readTextFile` and
* Optional fs injection seam. When provided, `BridgeClient.readTextFile` and
* `BridgeClient.writeTextFile` delegate every ACP fs call to this
* implementation instead of using BridgeClient's inline
* `fs.realpath` / `fs.writeFile` / `fs.readFile` proxy.
*
* The immediate F1 follow-up will land a serve-side adapter that
* wraps PR 18's `WorkspaceFileSystem` and a `runQwenServe` wiring
* wraps its `WorkspaceFileSystem` and a `runQwenServe` wiring
* patch so production `qwen serve` writes pick up its TOCTOU +
* symlink-substitution + trust-gate + `.gitignore` + audit
* machinery — closing the post-PR-18 follow-up thread about
* machinery — closing the follow-up thread about
* `BridgeClient`'s inline fs proxy bypassing `WorkspaceFileSystem`
* (originally raised in #4250 review). Until that lands, BridgeClient's inline
* (originally raised in code review). Until that lands, BridgeClient's inline
* proxy continues to handle writes (current behavior preserved).
*
* When omitted (tests, Mode A in-process consumers, channels /
Expand All @@ -310,7 +309,7 @@ export interface BridgeOptions {
*/
fileSystem?: BridgeFileSystem;
/**
* #4175 F3 Commit 2 — active permission mediation policy for the
* -- active permission mediation policy for the
* `MultiClientPermissionMediator`. When omitted, defaults to
* `'first-responder'` (the pre-F3 behavior — any validated voter
* wins immediately). The bridge captures this once at construction
Expand All @@ -321,7 +320,7 @@ export interface BridgeOptions {
*/
permissionPolicy?: PermissionPolicy;
/**
* #4175 F3 Commit 2 — optional fixed quorum for `consensus` policy.
* -- optional fixed quorum for `consensus` policy.
* MUST be a positive integer if provided; the F3 settings layer
* validates this and fails startup on non-integer / non-positive
* values. Capped at `M = votersAtIssue.size` at request time to
Expand All @@ -330,7 +329,7 @@ export interface BridgeOptions {
*/
permissionConsensusQuorum?: number;
/**
* #4175 F3 Commit 2 — injection seam for the permission audit
* -- injection seam for the permission audit
* publisher.
*
* **When omitted**: the bridge falls back to
Expand Down
12 changes: 6 additions & 6 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ export interface HttpAcpBridge {
}>;

/**
* T2.8 (#4514): Add a runtime MCP server through the ACP child's
* Add a runtime MCP server through the ACP child's
* `McpClientManager.addRuntimeMcpServer`. On success, broadcasts an
* `mcp_server_added` event to every session bus. Soft-refuse
* (`budget_warning_only` skip) does NOT emit an event — the caller
Expand Down Expand Up @@ -473,7 +473,7 @@ export interface HttpAcpBridge {
>;

/**
* T2.8 (#4514): Remove a runtime MCP server through the ACP child's
* Remove a runtime MCP server through the ACP child's
* `McpClientManager.removeRuntimeMcpServer`. On success, broadcasts
* an `mcp_server_removed` event. Idempotent skip (`not_present`)
* does NOT emit — the caller receives the skip shape.
Expand All @@ -495,17 +495,17 @@ export interface HttpAcpBridge {

/**
* Restart a configured MCP server through the ACP child's
* `McpClientManager` (pre-F2) or transport pool (F2 #4175 commit 5).
* `McpClientManager` or transport pool.
* Pre-checks the live budget snapshot and returns a structured
* "skipped" response (200 OK) for soft refusals.
*
* F2 commit 5: under pool mode, a single `serverName` may map to
* Under pool mode, a single `serverName` may map to
* multiple `PoolEntry` instances (different fingerprints from
* per-session OAuth/env divergence). When `opts.entryIndex` is
* undefined, the pool restarts ALL matching entries in parallel via
* `Promise.allSettled` and returns the new `{entries: RestartResult[]}`
* shape. When `opts.entryIndex` is set, only that entry restarts
* (404 / not-found surfaces as `entries: []`). Pre-F2 daemons and
* (404 / not-found surfaces as `entries: []`). Older daemons and
* single-entry pool-mode responses keep the legacy
* `{restarted, durationMs}` shape so SDK clients that pre-date the
* `mcp_pool_restart` capability tag observe no diff.
Expand Down Expand Up @@ -581,7 +581,7 @@ export interface HttpAcpBridge {
readonly pendingPermissionCount: number;

/**
* #4175 F3 Commit 6 — active permission mediation policy. Reflects
* Active permission mediation policy. Reflects
* the value `runQwenServe` resolved from
* `settings.policy.permissionStrategy` (or the
* `'first-responder'` default). Surfaced through the
Expand Down
5 changes: 2 additions & 3 deletions packages/acp-bridge/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ import type { Stream } from '@agentclientprotocol/sdk';
/**
* One ACP NDJSON channel to a single agent. Tests inject a fake by
* replacing the channel factory; production uses
* `defaultSpawnChannelFactory` (lifted to `./spawnChannel.ts` in
* #4175 F1 step 1).
* `defaultSpawnChannelFactory` (in `./spawnChannel.ts`).
*
* This contract is consumed by the daemon HTTP bridge and is available
* for `packages/channels/base/AcpBridge.ts` and the VSCode IDE
* companion's `acpConnection.ts` to consume directly via
* `@qwen-code/acp-bridge/spawnChannel` instead of each reimplementing
* the child lifecycle. The adapter migrations land separately in F4.
* the child lifecycle. The adapter migrations land separately.
*/
export interface AcpChannel {
stream: Stream;
Expand Down
Loading