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
88 changes: 88 additions & 0 deletions docs/design/daemon-local-text-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Daemon local text reads

## Decision

`BridgeOptions.delegateReadTextFileToClient` defaults to `true`, preserving
generic ACP, IDE, remote, and virtual-filesystem behavior. Same-host `qwen
serve` runtimes set it to `false`, so the ACP initialize capability is
`{ readTextFile: false, writeTextFile: true }` and the child uses its regular
CLI filesystem service for text reads. Caller-injected bridges remain under
the caller's control.

## Behavior

Direct external text `read_file` calls use the normal CLI permission flow:
their default is `ask`, approval allows the read, and rejection prevents tool
execution. Allow rules and automatic approval modes behave as in the CLI.
Non-text `read_file` paths were already read locally by the child and are
unchanged.

Because the capability applies to `FileSystemService.readTextFile`, shared
text pre-reads used by write, edit, notebook, sed, and artifact operations also
move to the regular CLI filesystem service. This intentionally accepts the
CLI's read-side limits and behavior instead of WFS's 256 KiB returned-output
and full-snapshot cap, 8 MiB large-text scan cap, read audit, symlink rejection,
and read-side TOCTOU protections. Direct `read_file` still applies the core line
and output limits, subject to their existing configuration.

This document is the single owner of that tradeoff list. Other documents
reference it rather than restating the limits, so tuning one of them does not
leave stale copies behind.

### What this does not fix

Reads become child-local; final ACP text writes stay delegated. The reported
failure in #8618 therefore still reproduces for the `write_file`, `replace`,
and `notebook_edit` family, only later in the sequence: the pre-read now
succeeds locally, the diff renders, the user approves, and the delegated write
is then refused by the workspace filesystem because the target is outside the
workspace. The model can still fall back to shell at that point. Moving writes
child-local as well would give up the trust gate, symlink rejection, TOCTOU
protection, atomic temp-and-rename with mode preservation, and the write audit,
which is a materially larger concession than the read change; it is deliberately
out of scope here and tracked separately.

### Pre-approval exposure in the daemon

A confirmation payload is built by reading the file, so an edit or write
confirmation for an out-of-workspace path now carries that file's content in
its diff. The daemon fans that payload out to every attached SSE subscriber
before the approval decision exists. In the interactive CLI the same diff is
seen only by the person at the terminal. This follows from treating
authenticated daemon clients as one security principal, and is called out here
because that framing is easy to read past.

HTTP filesystem routes such as `/glob` and `/list` remain workspace-scoped.
Agent `glob`, `ls`, `grep`, and other discovery-tool behavior is unchanged by
this capability. Final ACP `writeTextFile` content writes stay delegated through
`WorkspaceFileSystem`, retaining workspace, trust, symlink, atomic-write, and
audit enforcement. This does not imply that every agent write or helper
operation goes through WFS.

## Resource and audit boundaries

A child-local text read does not emit WFS `fs.access`; direct external
`read_file` retains its permission audit and core file-operation telemetry.
Same-host reads run under the daemon user's OS identity. `qwen serve` assumes
one machine, one UID, and one security principal; it is not an OS sandbox.

## Compatibility

Only the default embedded daemon bridge and primary, static-secondary, and
dynamic `qwen serve` workspace runtimes disable read delegation. The WFS
adapter keeps its read implementation so an unexpected or
capability-violating delegated read still reaches the workspace boundary and
fails closed for external paths.

That "fails closed" is bounded, not absolute. `AcpFileSystemService` has a
second, pre-existing bypass: when a delegated read is refused with
`path_outside_workspace` or `symlink_escape`, it retries the read locally if
the path's realpath sits under one of its managed read roots. Those roots
include `/tmp` unconditionally on POSIX, plus anything named by
`QWEN_ACP_LOCAL_READ_ROOTS`. So the boundary is fail-closed only for paths
outside those roots. The daemon neutralizes the env-supplied half by setting
`QWEN_ACP_LOCAL_READ_ROOTS` empty for the child.

With the capability off, that retry path is unreachable in the daemon anyway —
the capability check returns before the delegated call is attempted — so it
now guards only generic ACP hosts that keep delegation enabled.
9 changes: 8 additions & 1 deletion docs/design/serve-large-text-range-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,14 @@ reaching this boundary:

- `GET /file`
- ACP HTTP `_qwen/file/read`
- the injected ACP `readTextFile` adapter

The injected ACP `readTextFile` adapter is no longer a production consumer:
same-host daemon runtimes advertise `readTextFile: false`, so agent text reads
are served by the child's regular CLI filesystem service and never reach this
boundary. The adapter's read path is kept as a fail-closed guard for an
unexpected or capability-violating delegated read, but the concurrent-append,
truncation, and symlink-replacement guarantees verified below no longer apply
to any agent read. See [daemon local text reads](./daemon-local-text-reads.md).

Windowless reads used by workspace setup retain the existing 256 KiB
full-snapshot refusal.
Expand Down
3 changes: 2 additions & 1 deletion docs/developers/daemon/03-acp-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Each active `WorkspaceRuntime` owns one `HttpAcpBridge` instance. Production att
- Per-session FIFO for `setSessionModel` calls so concurrent attaches with different models do not race the agent.
- Per-session `EventBus` that drives `GET /session/:id/events` (see [`10-event-bus.md`](./10-event-bus.md)).
- Permission flow: `BridgeClient.requestPermission` → `MultiClientPermissionMediator.request` → fan-out → vote collection → ACP response (see [`04-permission-mediation.md`](./04-permission-mediation.md)).
- File I/O: `BridgeFileSystem` adapter for ACP `readTextFile` / `writeTextFile` calls (see [`07-workspace-filesystem.md`](./07-workspace-filesystem.md)).
- File I/O: `BridgeFileSystem` adapter for ACP reads and writes; same-host daemon runtimes advertise `readTextFile: false` so normal text reads stay in the child while final text writes remain delegated (see [`07-workspace-filesystem.md`](./07-workspace-filesystem.md)).
- extMethod RPCs for workspace-level status (`/workspace/mcp`, `/workspace/skills`, `/workspace/providers`), MCP restart, and the optional private managed Tool Guard callback.
- Lifecycle: graceful `shutdown()` with `KILL_HARD_DEADLINE_MS` (10s) per channel; synchronous `killAllSync()` for second-signal force-exit.

Expand Down Expand Up @@ -209,6 +209,7 @@ sequenceDiagram
| `persistApprovalMode`, `persistDisabledTools` | — | Settings-write hooks for the Wave 4 mutation routes. |
| `contextFilename` | from `settings.json`'s `context.fileName` | Overrides `getCurrentGeminiMdFilename`. |
| `statusProvider` | (none) | Daemon-host preflight cells (`DaemonStatusProvider`). |
| `delegateReadTextFileToClient` | `true` | Set `false` only for same-host runtimes so every child `FileSystemService.readTextFile` consumer uses the regular CLI filesystem service. |
| `fileSystem` | (none) | `BridgeFileSystem` adapter for ACP `readTextFile` / `writeTextFile`. |
| `permissionPolicy` | from `settings.json`'s `policy.permissionStrategy` | One of `first-responder` / `designated` / `consensus` / `local-only`. |
| `permissionConsensusQuorum` | from `settings.json` | N for consensus policy. |
Expand Down
15 changes: 12 additions & 3 deletions docs/developers/daemon/07-workspace-filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Overview

The daemon never lets HTTP routes or ACP-side agent calls touch the host filesystem directly. Every read, write, list, glob, and stat goes through the `WorkspaceFileSystem` boundary (`packages/cli/src/serve/fs/`), which provides:
Daemon HTTP file routes and delegated ACP `readTextFile` / `writeTextFile` calls go through the `WorkspaceFileSystem` boundary (`packages/cli/src/serve/fs/`), which provides:

- **Path resolution** — canonicalize paths and reject anything escaping the bound workspace, including via symlinks.
- **Trust gating** — refuse writes when the workspace is not trusted (`untrusted_workspace`).
Expand All @@ -11,7 +11,16 @@ The daemon never lets HTTP routes or ACP-side agent calls touch the host filesys
- **Audit** — every access / denial emits a structured event for `PermissionAuditRing` / monitoring.
- **Typed errors** — closed `FsErrorKind` union mapped to HTTP statuses.

The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST /file/edit`, `GET /list`, `GET /glob`, `GET /stat`) and the ACP-side `BridgeFileSystem` adapter (so agent-driven `readTextFile` / `writeTextFile` calls get the same gates) both go through this boundary.
The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST /file/edit`, `GET /list`, `GET /glob`, `GET /stat`) use this boundary. In the production daemon, ACP calls that remain delegated reach WFS through the injected bridge adapter; generic bridge callers use WFS only when they inject such an adapter. Production same-host `qwen serve` runtimes advertise `readTextFile: false`, so all child `FileSystemService.readTextFile` consumers use the regular CLI filesystem service; final ACP `writeTextFile` content writes remain delegated through WFS.

That text-read capability slice covers direct `read_file` plus the shared pre-reads used by write, edit, notebook, sed, and artifact operations:

- It intentionally accepts regular CLI read behavior rather than the WFS read-side guarantees. [The design doc](../../design/daemon-local-text-reads.md) owns the exact list of what is given up.
- The same doc records why #8618 still reproduces for the write and edit family even after this change, and the bounded sense in which the retained adapter read path "fails closed".
- Direct external `read_file` keeps the normal CLI permission rules and core file-operation telemetry.
- HTTP filesystem routes remain workspace-scoped, and agent discovery-tool behavior is unchanged by this capability.
- Auxiliary actions such as parent-directory creation and shell commands are separate existing paths, not covered by this boundary.
- `qwen serve` assumes a same-machine, same-UID security principal and is not an OS sandbox.

## Responsibilities

Expand Down Expand Up @@ -66,7 +75,7 @@ interface BridgeFileSystem {
}
```

This is the injection point for ACP `readTextFile` / `writeTextFile`. Bridge tests and Mode A embedded callers can omit it on `BridgeOptions`; `BridgeClient` falls back to its inline `fs.readFile` / `fs.writeFile` proxy (preserves pre-F1 behavior). Production `qwen serve` wires `BridgeFileSystem` through `createBridgeFileSystemAdapter(fsFactory)` (`packages/cli/src/serve/bridge-file-system-adapter.ts`) so agent-side ACP writes pick up the same TOCTOU, symlink, trust-gate, and audit gates the HTTP routes use.
This is the injection point for ACP `readTextFile` / `writeTextFile`. Bridge tests and Mode A embedded callers can omit it on `BridgeOptions`; `BridgeClient` falls back to its inline `fs.readFile` / `fs.writeFile` proxy (preserves pre-F1 behavior). Production `qwen serve` wires `BridgeFileSystem` through `createBridgeFileSystemAdapter(fsFactory)` (`packages/cli/src/serve/bridge-file-system-adapter.ts`) and sets `delegateReadTextFileToClient: false`. Capability-compliant children therefore read text locally and delegate final ACP text writes. The adapter retains its read implementation so unexpected or capability-violating delegated reads still encounter WFS's workspace boundary.

Two defensive properties the adapter MUST preserve (because the inline proxy is fully bypassed when the adapter is injected):

Expand Down
1 change: 1 addition & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ provider decision with their normal tool policy and isolation boundary.
- **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin <pattern>`** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended for full hardening since `/health` and `/demo` remain pre-auth on loopback by default) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo). Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the `/demo` page) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. **Browser webuis without `--allow-origin` configured** still fall back to the same Stage 1 options as before: package as a native shell (Electron/Tauri) so no `Origin` header is sent, or front the daemon with a same-origin reverse proxy.
- **Chrome extension browser automation is separate from framing.** `qwen serve --allow-origin chrome-extension://<id>` lets the extension frame the Web Shell and connect to the daemon. Console/network/screenshot/click tools require an external CDP MCP adapter command: `QWEN_CDP_MCP_COMMAND=/path/to/cdp-mcp-adapter qwen serve --allow-origin chrome-extension://<id>`. The main CLI package does not bundle a browser automation adapter; clients can check `caps.features.includes('browser_automation_mcp')` before presenting those tools as available.
- **A spawned `qwen --acp` child receives its owning runtime's effective environment.** The daemon freezes a process-env base, applies that workspace's settings/env-file overlay to a runtime-local snapshot, and never writes the overlay back to `process.env`; same-named keys in another runtime do not cross over. `QWEN_SERVER_TOKEN` is scrubbed before spawn because the agent does not need the daemon bearer. Base credentials such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `QWEN_*`, and `DASHSCOPE_API_KEY` otherwise pass through unless the runtime overlay changes them. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc`, `~/.aws/credentials`, or `~/.npmrc` is reachable by prompt injection regardless. Environment isolation between runtimes is not an operating-system security boundary; do not run `qwen serve` under an identity that has credentials you would not trust the agent with.
- **Agent text reads are child-local and follow the regular CLI permission rules, not the workspace filesystem boundary.** Direct `read_file` can reach host text paths outside every registered workspace: external paths default to confirmation, and allow rules or approval modes may approve them automatically. Approved reads use the configurable CLI output limits rather than the workspace filesystem's returned-output, full-snapshot, and large-text scan caps. This applies to every shared text-read consumer, so the pre-reads performed by write, edit, notebook, sed, and artifact operations lose those caps together with the workspace filesystem's read audit, symlink rejection, and read-side TOCTOU protections — see [the design doc](../design/daemon-local-text-reads.md) for the exact list. Because a confirmation payload is built by reading the file, an out-of-workspace diff is fanned out to **every** attached SSE subscriber before anyone approves it — in the interactive CLI that content is seen only by the person at the terminal. Treat authenticated daemon clients as the same security principal. HTTP filesystem routes remain workspace-scoped and still refuse these paths, agent discovery-tool behavior is unchanged, and final ACP `writeTextFile` content writes continue through the workspace filesystem.
- **Per-subscriber bounded SSE queues** — a slow client that overflows its queue gets a `client_evicted` terminal frame and is closed; one stuck consumer can't pin the daemon.
- **Per-session prompt admission cap** — defaults to 5 accepted-but-unsettled prompts per session. A buggy client cannot enqueue unbounded prompt promises or temporary SSE waits for one session.
- **Graceful shutdown** — SIGINT/SIGTERM drain the agent children before closing the listener (10s deadline per child).
Expand Down
Loading
Loading