Skip to content

Commit 688d644

Browse files
doudouOUCqwencoder
andauthored
feat(serve): add workspace file write/edit routes (#4175 PR20) (#4280)
* feat(serve): add workspace file write/edit routes Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(serve): bind file hashes to text snapshots Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(serve): tighten read-bytes snapshot and create-mode publish - readBytesWindow: re-stat the open fd after read and require unchanged ino+size+mtime before emitting the response. Mirrors the hardened text-snapshot path so the full-window hash can no longer pair with bytes that drifted under in-place rewrite or append. Surface drift as retryable hash_mismatch. - atomicWriteTextResolvedFile: reject a symlinked parent up-front as defense-in-depth ahead of the parent-fd publish follow-up referenced by assertInodeStableAfterRead. - atomicWriteTextResolvedFile: publish create-mode writes via link()+unlink() instead of rename(). POSIX rename() overwrites an existing regular file, so a racing external process could break the public create contract; link() returns EEXIST atomically and is portable across POSIX/NTFS. The early assertCreateTargetAbsent check stays for friendlier errors on the non-racing path. --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 36760ca commit 688d644

25 files changed

Lines changed: 2557 additions & 266 deletions

docs/developers/examples/daemon-client-quickstart.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ function handleEvent(event: DaemonEvent): void {
109109
}
110110
```
111111

112+
## Workspace file helpers
113+
114+
File routes are workspace-scoped, not session-scoped, so they live on
115+
`DaemonClient` directly:
116+
117+
```ts
118+
const file = await client.readWorkspaceFile('src/main.ts');
119+
120+
const updated = await client.editWorkspaceFile({
121+
path: 'src/main.ts',
122+
oldText: 'timeout: 30000',
123+
newText: 'timeout: 60000',
124+
expectedHash: file.hash!,
125+
});
126+
127+
console.log(updated.hash);
128+
```
129+
130+
`expectedHash` is SHA-256 over the raw on-disk bytes. `mode: "replace"` and
131+
`editWorkspaceFile()` require it so stale clients do not overwrite a file they
132+
did not just read. Write/edit require bearer-token configuration even on
133+
loopback; start the daemon with `--token` or `QWEN_SERVER_TOKEN` before using
134+
them.
135+
112136
## Reconnect with `Last-Event-ID`
113137

114138
If your client process restarts mid-session, replay events you missed:

docs/developers/qwen-serve-protocol.md

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
9999
'session_set_model', 'client_identity', 'client_heartbeat',
100100
'session_permission_vote', 'permission_vote', 'workspace_mcp', 'workspace_skills',
101101
'workspace_providers', 'session_context', 'session_supported_commands',
102-
'session_close', 'session_metadata', 'mcp_guardrails']
102+
'session_close', 'session_metadata', 'mcp_guardrails',
103+
'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write']
103104
```
104105

105106
`session_scope_override` is the negotiation handle for the per-request `sessionScope` field on `POST /session` (see below). Older daemons silently ignore the field, so SDK clients should pre-flight `caps.features` for this tag before sending it.
@@ -118,6 +119,15 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
118119

119120
> ⚠️ **PR 14 v1 scope: per-session, not per-workspace.** Each ACP session inside the daemon constructs its own `Config` + `McpClientManager` (via `acpAgent.newSessionConfig`). The budget caps live MCP clients **per session**; each session independently reads `QWEN_SERVE_MCP_CLIENT_BUDGET` from the forwarded env. With `--mcp-client-budget=10` and 5 concurrent ACP sessions, the actual live MCP client count can reach 5 × 10 = 50 across the daemon. The `GET /workspace/mcp` snapshot reads the **bootstrap session's** `McpClientManager` accounting only — the `budgets[0].scope: 'session'` value is the honest signal that this is per-session, not aggregated. **Wave 5 PR 23 (shared MCP pool)** will introduce a workspace-scoped manager and add a `scope: 'workspace'` cell alongside the per-session cell for true cross-session aggregation. v1 is the in-process counter + soft enforcement foundation that PR 23 builds on.
120121
122+
`workspace_file_read` covers the text/list/stat/glob workspace file routes
123+
(`GET /file`, `GET /list`, `GET /glob`, `GET /stat`). `workspace_file_bytes`
124+
covers `GET /file/bytes`, which was added later so clients can pre-flight raw
125+
byte-window support against PR19-era daemons. `workspace_file_write` covers
126+
the hash-aware text mutation routes (`POST /file/write`, `POST /file/edit`).
127+
The write tag means the route contract exists; it does not mean the current
128+
deployment is open for anonymous mutation. Write/edit are strict mutation
129+
routes and require a configured bearer token even on loopback.
130+
121131
**Conditional tags.** A small number of feature tags are advertised only when the matching deployment toggle is on. Tag presence = behavior is on; absence = either an older daemon predating the tag, OR a current daemon where the operator did not opt in. Currently:
122132

123133
| Tag | Advertised when … |
@@ -605,6 +615,173 @@ carries a single `ServeStatusCell` describing the failure and the cells
605615
fall back to `not_started` ACP placeholders. Daemon-level cells are still
606616
returned.
607617

618+
### Workspace file routes
619+
620+
All file paths are resolved through the daemon's bound workspace. Responses use
621+
workspace-relative paths and never return absolute filesystem paths for normal
622+
success cases. Successful file responses include:
623+
624+
```http
625+
Cache-Control: no-store
626+
X-Content-Type-Options: nosniff
627+
```
628+
629+
Filesystem errors use this JSON shape:
630+
631+
```json
632+
{
633+
"errorKind": "hash_mismatch",
634+
"error": "expected sha256:..., found sha256:...",
635+
"hint": "re-read the file and retry with the latest hash",
636+
"status": 409
637+
}
638+
```
639+
640+
`errorKind` values include `path_outside_workspace`, `symlink_escape`,
641+
`path_not_found`, `binary_file`, `file_too_large`, `untrusted_workspace`,
642+
`permission_denied`, `parse_error`, `hash_mismatch`,
643+
`file_already_exists`, `text_not_found`, and `ambiguous_text_match`.
644+
645+
#### `GET /file`
646+
647+
Reads a text file. Query params: `path` (required), `maxBytes`, `line`, and
648+
`limit`. The daemon rejects binary files and files above the text read cap.
649+
The response includes `hash`, a SHA-256 digest over the raw on-disk bytes for
650+
the whole file, even when `line`, `limit`, or `maxBytes` returned a slice.
651+
652+
```json
653+
{
654+
"kind": "file",
655+
"path": "src/index.ts",
656+
"content": "export {};\n",
657+
"encoding": "utf-8",
658+
"bom": false,
659+
"lineEnding": "lf",
660+
"sizeBytes": 11,
661+
"returnedBytes": 11,
662+
"truncated": false,
663+
"hash": "sha256:...",
664+
"matchedIgnore": null,
665+
"originalLineCount": null
666+
}
667+
```
668+
669+
#### `GET /file/bytes`
670+
671+
Reads raw bytes from a file without decoding. Query params: `path` (required),
672+
`offset` (default `0`), and `maxBytes` (default `65536`, max `262144`). This
673+
route supports bounded windows on large binary files without slurping the whole
674+
file. The response includes `hash` only when the returned window covers the
675+
entire file.
676+
677+
```json
678+
{
679+
"kind": "file_bytes",
680+
"path": "assets/logo.png",
681+
"offset": 0,
682+
"sizeBytes": 3912,
683+
"returnedBytes": 3912,
684+
"truncated": false,
685+
"contentBase64": "...",
686+
"hash": "sha256:..."
687+
}
688+
```
689+
690+
#### `POST /file/write`
691+
692+
Creates or replaces a text file. This is a strict mutation route: on loopback
693+
without a configured token it returns `401 { "code": "token_required" }`.
694+
With `--require-auth`, the global bearer middleware rejects unauthenticated
695+
requests before the route runs.
696+
697+
Body:
698+
699+
```json
700+
{
701+
"path": "src/new.ts",
702+
"content": "export const value = 1;\n",
703+
"mode": "create"
704+
}
705+
```
706+
707+
```json
708+
{
709+
"path": "src/existing.ts",
710+
"content": "export const value = 2;\n",
711+
"mode": "replace",
712+
"expectedHash": "sha256:..."
713+
}
714+
```
715+
716+
`mode` must be `create` or `replace`. `create` never overwrites an existing
717+
file (`409 file_already_exists`). `replace` requires `expectedHash`; missing or
718+
malformed hashes are `400 parse_error`, and stale hashes are
719+
`409 hash_mismatch`. `expectedHash` is `sha256:` plus 64 lowercase hex
720+
characters, computed over raw on-disk bytes.
721+
722+
`bom`, `encoding`, and `lineEnding` may be supplied. Replacement preserves the
723+
existing file's encoding profile by default; explicit fields override it.
724+
Binary writes are out of scope.
725+
726+
The daemon writes to a random temp file in the target directory, fsyncs where
727+
supported, re-checks the current hash immediately before `rename()`, then
728+
renames into place. This prevents partial-file observation and serializes
729+
daemon-originated writes to the same file, but it is not a cross-process
730+
kernel compare-and-swap: an external editor can still race in the tiny window
731+
between final hash check and rename.
732+
733+
```json
734+
{
735+
"kind": "file_write",
736+
"path": "src/existing.ts",
737+
"mode": "replace",
738+
"created": false,
739+
"sizeBytes": 24,
740+
"hash": "sha256:...",
741+
"encoding": "utf-8",
742+
"bom": false,
743+
"lineEnding": "lf",
744+
"matchedIgnore": null
745+
}
746+
```
747+
748+
#### `POST /file/edit`
749+
750+
Applies one exact text replacement to an existing text file. This is also a
751+
strict mutation route and requires `expectedHash`.
752+
753+
```json
754+
{
755+
"path": "src/config.ts",
756+
"oldText": "timeout: 30000",
757+
"newText": "timeout: 60000",
758+
"expectedHash": "sha256:..."
759+
}
760+
```
761+
762+
`oldText` must be non-empty and occur exactly once. No match returns
763+
`422 text_not_found`; multiple matches return `422 ambiguous_text_match`.
764+
The route preserves encoding, BOM, and line endings, and re-checks
765+
`expectedHash` immediately before the atomic rename.
766+
767+
Explicit writes/edits to ignored paths are allowed because the authenticated
768+
caller named the path. Success responses and audit events include
769+
`matchedIgnore: "file" | "directory" | null`.
770+
771+
```json
772+
{
773+
"kind": "file_edit",
774+
"path": "src/config.ts",
775+
"replacements": 1,
776+
"sizeBytes": 128,
777+
"hash": "sha256:...",
778+
"encoding": "utf-8",
779+
"bom": false,
780+
"lineEnding": "lf",
781+
"matchedIgnore": null
782+
}
783+
```
784+
608785
### `GET /session/:id/context`
609786

610787
```json

docs/users/qwen-serve.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ to populate them. Failures map to a closed `errorKind` enum (`missing_binary`,
7171
`parse_error`, `blocked_egress`) so client UIs can render structured
7272
remediation.
7373

74+
The daemon also exposes workspace file helpers:
75+
76+
- `GET /file` reads text files and returns a raw-byte `sha256:<hex>` hash.
77+
- `GET /file/bytes` reads bounded raw byte windows and returns base64 content.
78+
- `POST /file/write` creates or replaces text files.
79+
- `POST /file/edit` applies one exact text replacement.
80+
81+
Write/edit are **strict mutation routes**: even on loopback they require a
82+
configured bearer token, otherwise they return `token_required`. Replacements
83+
and edits require the latest `expectedHash` from `GET /file` (or a full-window
84+
`GET /file/bytes`). `create` never overwrites. Explicit writes to ignored paths
85+
are allowed but audited. Binary writes, delete/move/mkdir, and recursive parent
86+
creation are not part of this surface.
87+
7488
### 3. Open a session
7589

7690
```bash

packages/cli/src/serve/capabilities.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,18 @@ export const SERVE_CAPABILITY_REGISTRY = {
104104
// others for free, and a future deprecation would have to coordinate
105105
// across all four anyway. Per-route tags would force four
106106
// simultaneous registry entries with no operator-meaningful
107-
// difference between them. Mutating routes (`POST /file/write`,
108-
// `POST /file/edit`) ship under a separate `workspace_file_write`
109-
// tag in PR 20.
107+
// difference between them.
110108
workspace_file_read: { since: 'v1' },
109+
// Issue #4175 PR 20. Daemon supports bounded raw byte reads via
110+
// `GET /file/bytes`. This is separate from `workspace_file_read`
111+
// because PR19 daemons already advertise the text/list/stat/glob
112+
// surface without byte-window support.
113+
workspace_file_bytes: { since: 'v1' },
114+
// Issue #4175 PR 20. Daemon supports hash-aware text mutation routes
115+
// (`POST /file/write`, `POST /file/edit`) behind the strict mutation
116+
// gate. Clients should still pre-flight `require_auth` separately for
117+
// deployment posture; this tag only means the route contract exists.
118+
workspace_file_write: { since: 'v1' },
111119
// Issue #4175 PR 15. Daemon was booted with `--require-auth` (or
112120
// `requireAuth: true`), so even loopback callers must carry a bearer
113121
// token. Advertised CONDITIONALLY — only when the flag is on — so

packages/cli/src/serve/fs/errors.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@ describe('FsError', () => {
2020
['path_not_found', 404],
2121
['binary_file', 422],
2222
['file_too_large', 413],
23+
['hash_mismatch', 409],
24+
['file_already_exists', 409],
25+
['text_not_found', 422],
26+
['ambiguous_text_match', 422],
2327
['untrusted_workspace', 403],
2428
['permission_denied', 403],
29+
['io_error', 503],
30+
['internal_error', 500],
2531
['parse_error', 400],
2632
];
2733
for (const [kind, status] of cases) {

packages/cli/src/serve/fs/errors.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export type FsErrorKind =
2020
| 'path_not_found'
2121
| 'binary_file'
2222
| 'file_too_large'
23+
| 'hash_mismatch'
24+
| 'file_already_exists'
25+
| 'text_not_found'
26+
| 'ambiguous_text_match'
2327
| 'untrusted_workspace'
2428
| 'permission_denied'
2529
/**
@@ -56,7 +60,7 @@ export type FsErrorKind =
5660
* boundary is being asked to model a transport-level concern that
5761
* doesn't belong here (5xx, 401/403 from auth, etc.).
5862
*/
59-
export type FsErrorStatus = 400 | 403 | 404 | 413 | 422 | 500 | 503;
63+
export type FsErrorStatus = 400 | 403 | 404 | 409 | 413 | 422 | 500 | 503;
6064

6165
/**
6266
* Default HTTP status mapping. Centralized here so callers can throw
@@ -72,6 +76,10 @@ const DEFAULT_STATUS_BY_KIND: Record<FsErrorKind, FsErrorStatus> = {
7276
path_not_found: 404,
7377
binary_file: 422,
7478
file_too_large: 413,
79+
hash_mismatch: 409,
80+
file_already_exists: 409,
81+
text_not_found: 422,
82+
ambiguous_text_match: 422,
7583
untrusted_workspace: 403,
7684
permission_denied: 403,
7785
io_error: 503,

packages/cli/src/serve/fs/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,22 @@ export {
4242
} from './audit.js';
4343
export {
4444
createWorkspaceFileSystemFactory,
45+
isContentHash,
46+
type ContentHash,
4547
type CreateWorkspaceFileSystemFactoryDeps,
4648
type FsEntry,
4749
type FsStat,
4850
type GlobOptions,
4951
type ListOptions,
52+
type ReadBytesOptions,
53+
type ReadBytesOutcome,
5054
type ReadMeta,
5155
type ReadTextOptions,
5256
type RequestContext,
5357
type WorkspaceFileSystem,
5458
type WorkspaceFileSystemFactory,
59+
type WriteMode,
5560
type WriteOutcome,
61+
type WriteTextAtomicOptions,
62+
type WriteTextAtomicOutcome,
5663
} from './workspaceFileSystem.js';

0 commit comments

Comments
 (0)