feat(serve): add workspace file write/edit routes (#4175 PR20) (#4280)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* 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>
This commit is contained in:
jinye 2026-05-18 22:37:08 +08:00 committed by GitHub
parent 36760ca63c
commit 688d64416e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2555 additions and 264 deletions

View file

@ -109,6 +109,30 @@ function handleEvent(event: DaemonEvent): void {
}
```
## Workspace file helpers
File routes are workspace-scoped, not session-scoped, so they live on
`DaemonClient` directly:
```ts
const file = await client.readWorkspaceFile('src/main.ts');
const updated = await client.editWorkspaceFile({
path: 'src/main.ts',
oldText: 'timeout: 30000',
newText: 'timeout: 60000',
expectedHash: file.hash!,
});
console.log(updated.hash);
```
`expectedHash` is SHA-256 over the raw on-disk bytes. `mode: "replace"` and
`editWorkspaceFile()` require it so stale clients do not overwrite a file they
did not just read. Write/edit require bearer-token configuration even on
loopback; start the daemon with `--token` or `QWEN_SERVER_TOKEN` before using
them.
## Reconnect with `Last-Event-ID`
If your client process restarts mid-session, replay events you missed:

View file

@ -99,7 +99,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'session_set_model', 'client_identity', 'client_heartbeat',
'session_permission_vote', 'permission_vote', 'workspace_mcp', 'workspace_skills',
'workspace_providers', 'session_context', 'session_supported_commands',
'session_close', 'session_metadata', 'mcp_guardrails']
'session_close', 'session_metadata', 'mcp_guardrails',
'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write']
```
`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
> ⚠️ **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.
`workspace_file_read` covers the text/list/stat/glob workspace file routes
(`GET /file`, `GET /list`, `GET /glob`, `GET /stat`). `workspace_file_bytes`
covers `GET /file/bytes`, which was added later so clients can pre-flight raw
byte-window support against PR19-era daemons. `workspace_file_write` covers
the hash-aware text mutation routes (`POST /file/write`, `POST /file/edit`).
The write tag means the route contract exists; it does not mean the current
deployment is open for anonymous mutation. Write/edit are strict mutation
routes and require a configured bearer token even on loopback.
**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:
| Tag | Advertised when … |
@ -605,6 +615,173 @@ carries a single `ServeStatusCell` describing the failure and the cells
fall back to `not_started` ACP placeholders. Daemon-level cells are still
returned.
### Workspace file routes
All file paths are resolved through the daemon's bound workspace. Responses use
workspace-relative paths and never return absolute filesystem paths for normal
success cases. Successful file responses include:
```http
Cache-Control: no-store
X-Content-Type-Options: nosniff
```
Filesystem errors use this JSON shape:
```json
{
"errorKind": "hash_mismatch",
"error": "expected sha256:..., found sha256:...",
"hint": "re-read the file and retry with the latest hash",
"status": 409
}
```
`errorKind` values include `path_outside_workspace`, `symlink_escape`,
`path_not_found`, `binary_file`, `file_too_large`, `untrusted_workspace`,
`permission_denied`, `parse_error`, `hash_mismatch`,
`file_already_exists`, `text_not_found`, and `ambiguous_text_match`.
#### `GET /file`
Reads a text file. Query params: `path` (required), `maxBytes`, `line`, and
`limit`. The daemon rejects binary files and files above the text read cap.
The response includes `hash`, a SHA-256 digest over the raw on-disk bytes for
the whole file, even when `line`, `limit`, or `maxBytes` returned a slice.
```json
{
"kind": "file",
"path": "src/index.ts",
"content": "export {};\n",
"encoding": "utf-8",
"bom": false,
"lineEnding": "lf",
"sizeBytes": 11,
"returnedBytes": 11,
"truncated": false,
"hash": "sha256:...",
"matchedIgnore": null,
"originalLineCount": null
}
```
#### `GET /file/bytes`
Reads raw bytes from a file without decoding. Query params: `path` (required),
`offset` (default `0`), and `maxBytes` (default `65536`, max `262144`). This
route supports bounded windows on large binary files without slurping the whole
file. The response includes `hash` only when the returned window covers the
entire file.
```json
{
"kind": "file_bytes",
"path": "assets/logo.png",
"offset": 0,
"sizeBytes": 3912,
"returnedBytes": 3912,
"truncated": false,
"contentBase64": "...",
"hash": "sha256:..."
}
```
#### `POST /file/write`
Creates or replaces a text file. This is a strict mutation route: on loopback
without a configured token it returns `401 { "code": "token_required" }`.
With `--require-auth`, the global bearer middleware rejects unauthenticated
requests before the route runs.
Body:
```json
{
"path": "src/new.ts",
"content": "export const value = 1;\n",
"mode": "create"
}
```
```json
{
"path": "src/existing.ts",
"content": "export const value = 2;\n",
"mode": "replace",
"expectedHash": "sha256:..."
}
```
`mode` must be `create` or `replace`. `create` never overwrites an existing
file (`409 file_already_exists`). `replace` requires `expectedHash`; missing or
malformed hashes are `400 parse_error`, and stale hashes are
`409 hash_mismatch`. `expectedHash` is `sha256:` plus 64 lowercase hex
characters, computed over raw on-disk bytes.
`bom`, `encoding`, and `lineEnding` may be supplied. Replacement preserves the
existing file's encoding profile by default; explicit fields override it.
Binary writes are out of scope.
The daemon writes to a random temp file in the target directory, fsyncs where
supported, re-checks the current hash immediately before `rename()`, then
renames into place. This prevents partial-file observation and serializes
daemon-originated writes to the same file, but it is not a cross-process
kernel compare-and-swap: an external editor can still race in the tiny window
between final hash check and rename.
```json
{
"kind": "file_write",
"path": "src/existing.ts",
"mode": "replace",
"created": false,
"sizeBytes": 24,
"hash": "sha256:...",
"encoding": "utf-8",
"bom": false,
"lineEnding": "lf",
"matchedIgnore": null
}
```
#### `POST /file/edit`
Applies one exact text replacement to an existing text file. This is also a
strict mutation route and requires `expectedHash`.
```json
{
"path": "src/config.ts",
"oldText": "timeout: 30000",
"newText": "timeout: 60000",
"expectedHash": "sha256:..."
}
```
`oldText` must be non-empty and occur exactly once. No match returns
`422 text_not_found`; multiple matches return `422 ambiguous_text_match`.
The route preserves encoding, BOM, and line endings, and re-checks
`expectedHash` immediately before the atomic rename.
Explicit writes/edits to ignored paths are allowed because the authenticated
caller named the path. Success responses and audit events include
`matchedIgnore: "file" | "directory" | null`.
```json
{
"kind": "file_edit",
"path": "src/config.ts",
"replacements": 1,
"sizeBytes": 128,
"hash": "sha256:...",
"encoding": "utf-8",
"bom": false,
"lineEnding": "lf",
"matchedIgnore": null
}
```
### `GET /session/:id/context`
```json

View file

@ -71,6 +71,20 @@ to populate them. Failures map to a closed `errorKind` enum (`missing_binary`,
`parse_error`, `blocked_egress`) so client UIs can render structured
remediation.
The daemon also exposes workspace file helpers:
- `GET /file` reads text files and returns a raw-byte `sha256:<hex>` hash.
- `GET /file/bytes` reads bounded raw byte windows and returns base64 content.
- `POST /file/write` creates or replaces text files.
- `POST /file/edit` applies one exact text replacement.
Write/edit are **strict mutation routes**: even on loopback they require a
configured bearer token, otherwise they return `token_required`. Replacements
and edits require the latest `expectedHash` from `GET /file` (or a full-window
`GET /file/bytes`). `create` never overwrites. Explicit writes to ignored paths
are allowed but audited. Binary writes, delete/move/mkdir, and recursive parent
creation are not part of this surface.
### 3. Open a session
```bash

View file

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

View file

@ -20,8 +20,14 @@ describe('FsError', () => {
['path_not_found', 404],
['binary_file', 422],
['file_too_large', 413],
['hash_mismatch', 409],
['file_already_exists', 409],
['text_not_found', 422],
['ambiguous_text_match', 422],
['untrusted_workspace', 403],
['permission_denied', 403],
['io_error', 503],
['internal_error', 500],
['parse_error', 400],
];
for (const [kind, status] of cases) {

View file

@ -20,6 +20,10 @@ export type FsErrorKind =
| 'path_not_found'
| 'binary_file'
| 'file_too_large'
| 'hash_mismatch'
| 'file_already_exists'
| 'text_not_found'
| 'ambiguous_text_match'
| 'untrusted_workspace'
| 'permission_denied'
/**
@ -56,7 +60,7 @@ export type FsErrorKind =
* boundary is being asked to model a transport-level concern that
* doesn't belong here (5xx, 401/403 from auth, etc.).
*/
export type FsErrorStatus = 400 | 403 | 404 | 413 | 422 | 500 | 503;
export type FsErrorStatus = 400 | 403 | 404 | 409 | 413 | 422 | 500 | 503;
/**
* Default HTTP status mapping. Centralized here so callers can throw
@ -72,6 +76,10 @@ const DEFAULT_STATUS_BY_KIND: Record<FsErrorKind, FsErrorStatus> = {
path_not_found: 404,
binary_file: 422,
file_too_large: 413,
hash_mismatch: 409,
file_already_exists: 409,
text_not_found: 422,
ambiguous_text_match: 422,
untrusted_workspace: 403,
permission_denied: 403,
io_error: 503,

View file

@ -42,15 +42,22 @@ export {
} from './audit.js';
export {
createWorkspaceFileSystemFactory,
isContentHash,
type ContentHash,
type CreateWorkspaceFileSystemFactoryDeps,
type FsEntry,
type FsStat,
type GlobOptions,
type ListOptions,
type ReadBytesOptions,
type ReadBytesOutcome,
type ReadMeta,
type ReadTextOptions,
type RequestContext,
type WorkspaceFileSystem,
type WorkspaceFileSystemFactory,
type WriteMode,
type WriteOutcome,
type WriteTextAtomicOptions,
type WriteTextAtomicOutcome,
} from './workspaceFileSystem.js';

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fsp } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@ -61,6 +61,10 @@ async function teardown(h: Harness): Promise<void> {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
function rawHash(data: string | Buffer): `sha256:${string}` {
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
}
describe('WorkspaceFileSystem - resolve and stat', () => {
let h: Harness;
beforeEach(async () => {
@ -116,6 +120,7 @@ describe('WorkspaceFileSystem - readText', () => {
expect(out.content).toBe('hello\nworld\n');
expect(out.meta.lineEnding).toBe('lf');
expect(out.meta.sizeBytes).toBe(12);
expect(out.meta.hash).toBe(rawHash('hello\nworld\n'));
expect(out.meta.truncated).toBeUndefined();
});
@ -200,33 +205,27 @@ describe('WorkspaceFileSystem - readBytes', () => {
expect(buf[0]).toBe(0xab);
});
it('throws file_too_large when file size exceeds the hard MAX_READ_BYTES cap regardless of maxBytes', async () => {
it('reads a bounded window from a file larger than MAX_READ_BYTES', async () => {
const policy = await import('./policy.js');
const target = path.join(h.workspace, 'huge.bin');
await fsp.writeFile(target, Buffer.alloc(policy.MAX_READ_BYTES + 1));
const r = await h.fs.resolve('huge.bin', 'read');
const err = await h.fs.readBytes(r).catch((e) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_too_large');
const out = await h.fs.readBytesWindow(r, { maxBytes: 16 });
expect(out.sizeBytes).toBe(policy.MAX_READ_BYTES + 1);
expect(out.returnedBytes).toBe(16);
expect(out.truncated).toBe(true);
expect(out.hash).toBeUndefined();
});
it('readBytes catches concurrent post-stat growth via post-read size check', async () => {
// Pre-stat OOM gate sees a small file; post-read buf.length
// check catches the same-inode growth case (concurrent
// appender keeps the inode but extends past the cap). Test
// simulates by overwriting the file AFTER `resolve()` with a
// buffer larger than `MAX_READ_BYTES` — the readBytes call's
// pre-stat sees the large size and trips the hard cap; the
// post-read check is the defense-in-depth path for the case
// where stat passed but read picked up more bytes.
const policy = await import('./policy.js');
const small = path.join(h.workspace, 'grew.bin');
await fsp.writeFile(small, Buffer.alloc(64));
const r = await h.fs.resolve('grew.bin', 'read');
await fsp.writeFile(small, Buffer.alloc(policy.MAX_READ_BYTES + 1));
const err = await h.fs.readBytes(r).catch((e) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_too_large');
it('readBytesWindow honors byte offsets', async () => {
const target = path.join(h.workspace, 'offset.bin');
await fsp.writeFile(target, Buffer.from([1, 2, 3, 4, 5, 6]));
const r = await h.fs.resolve('offset.bin', 'read');
const out = await h.fs.readBytesWindow(r, { offset: 2, maxBytes: 3 });
expect(Array.from(out.buffer)).toEqual([3, 4, 5]);
expect(out.offset).toBe(2);
expect(out.returnedBytes).toBe(3);
expect(out.truncated).toBe(true);
});
});
@ -388,6 +387,52 @@ describe('WorkspaceFileSystem - write/edit', () => {
expect(access).toBeDefined();
});
it('writeTextAtomic creates a new file and returns a raw-byte hash', async () => {
const r = await h.fs.resolve('atomic-new.txt', 'write');
const out = await h.fs.writeTextAtomic(r, 'hello\n', {
mode: 'create',
});
expect(out.created).toBe(true);
expect(out.sizeBytes).toBe(6);
expect(out.hash).toBe(rawHash('hello\n'));
expect(await fsp.readFile(r as string, 'utf-8')).toBe('hello\n');
});
it('writeTextAtomic create rejects existing files', async () => {
const target = path.join(h.workspace, 'exists.txt');
await fsp.writeFile(target, 'old');
const r = await h.fs.resolve('exists.txt', 'write');
const err = await h.fs
.writeTextAtomic(r, 'new', { mode: 'create' })
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_already_exists');
expect(await fsp.readFile(target, 'utf-8')).toBe('old');
});
it('writeTextAtomic replace requires the current expectedHash', async () => {
const target = path.join(h.workspace, 'replace.txt');
await fsp.writeFile(target, 'old\n');
const r = await h.fs.resolve('replace.txt', 'write');
const err = await h.fs
.writeTextAtomic(r, 'new\n', {
mode: 'replace',
expectedHash: rawHash('not current'),
})
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('hash_mismatch');
expect(await fsp.readFile(target, 'utf-8')).toBe('old\n');
const out = await h.fs.writeTextAtomic(r, 'new\n', {
mode: 'replace',
expectedHash: rawHash('old\n'),
});
expect(out.created).toBe(false);
expect(out.hash).toBe(rawHash('new\n'));
expect(await fsp.readFile(target, 'utf-8')).toBe('new\n');
});
it('rejects oversize writes with file_too_large', async () => {
const r = await h.fs.resolve('huge.txt', 'write');
const err = await h.fs
@ -537,6 +582,48 @@ describe('WorkspaceFileSystem - write/edit', () => {
'file',
);
});
it('editAtomic applies exactly one replacement and returns the new hash', async () => {
const target = path.join(h.workspace, 'atomic-edit.txt');
await fsp.writeFile(target, 'foo=1\nbar=2\n');
const r = await h.fs.resolve('atomic-edit.txt', 'edit');
const out = await h.fs.editAtomic(r, 'foo=1', 'foo=42', {
expectedHash: rawHash('foo=1\nbar=2\n'),
});
expect(out.writtenBytes).toBe(Buffer.byteLength('foo=42\nbar=2\n'));
expect(out.hash).toBe(rawHash('foo=42\nbar=2\n'));
expect(await fsp.readFile(target, 'utf-8')).toBe('foo=42\nbar=2\n');
});
it('editAtomic validates expectedHash against the edited snapshot first', async () => {
const target = path.join(h.workspace, 'atomic-edit-stale.txt');
await fsp.writeFile(target, 'foo=1\n');
const r = await h.fs.resolve('atomic-edit-stale.txt', 'edit');
const err = await h.fs
.editAtomic(r, 'missing', 'foo=2', {
expectedHash: rawHash('different\n'),
})
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('hash_mismatch');
expect(await fsp.readFile(target, 'utf-8')).toBe('foo=1\n');
});
it('editAtomic rejects absent and ambiguous matches with typed errors', async () => {
const target = path.join(h.workspace, 'atomic-ambiguous.txt');
await fsp.writeFile(target, 'x\nx\n');
const r = await h.fs.resolve('atomic-ambiguous.txt', 'edit');
const missing = await h.fs
.editAtomic(r, 'y', 'z', { expectedHash: rawHash('x\nx\n') })
.catch((e: unknown) => e);
expect(isFsError(missing)).toBe(true);
expect((missing as { kind: string }).kind).toBe('text_not_found');
const ambiguous = await h.fs
.editAtomic(r, 'x', 'z', { expectedHash: rawHash('x\nx\n') })
.catch((e: unknown) => e);
expect(isFsError(ambiguous)).toBe(true);
expect((ambiguous as { kind: string }).kind).toBe('ambiguous_text_match');
});
});
describe('WorkspaceFileSystem - trust gate', () => {
@ -682,17 +769,53 @@ describe('WorkspaceFileSystem - TOCTOU + UTF-8 + cwd hardening', () => {
expect(outsideContent).toBe('foo=1\n');
});
it('readBytes opts.maxBytes is clamped to MAX_READ_BYTES (cannot widen past hard cap)', async () => {
it('writeTextAtomic does not write through a swapped temporary symlink', async () => {
const outside = path.join(h.scratch, 'temp-race-outside.txt');
await fsp.writeFile(outside, 'outside\n');
const originalOpen = fsp.open.bind(fsp);
const openSpy = vi
.spyOn(fsp, 'open')
.mockImplementation(async (...args) => {
const fh = await originalOpen(...args);
const candidate = String(args[0]);
if (
candidate.includes('.temp-race.txt.') &&
candidate.endsWith('.tmp') &&
args[1] === 'wx'
) {
const originalWriteFile = fh.writeFile.bind(fh);
vi.spyOn(fh, 'writeFile').mockImplementation(async (...writeArgs) => {
await fsp.unlink(candidate);
await fsp.symlink(outside, candidate, 'file');
return originalWriteFile(...writeArgs);
});
}
return fh;
});
try {
const r = await h.fs.resolve('temp-race.txt', 'write');
const err = await h.fs
.writeTextAtomic(r, 'secret\n', { mode: 'create' })
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('symlink_escape');
expect(await fsp.readFile(outside, 'utf-8')).toBe('outside\n');
} finally {
openSpy.mockRestore();
}
});
it('readBytes rejects opts.maxBytes above MAX_READ_BYTES', async () => {
const policy = await import('./policy.js');
const big = path.join(h.workspace, 'overrun.bin');
await fsp.writeFile(big, Buffer.alloc(policy.MAX_READ_BYTES + 1));
const r = await h.fs.resolve('overrun.bin', 'read');
// Caller tries to widen past the hard cap.
const err = await h.fs
.readBytes(r, { maxBytes: policy.MAX_READ_BYTES * 10 })
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_too_large');
expect((err as { kind: string }).kind).toBe('parse_error');
});
});

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
import { promises as fsp } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import request from 'supertest';
import { Ignore } from '@qwen-code/qwen-code-core';
@ -63,6 +63,10 @@ function loopbackHost(): string {
return `127.0.0.1:${baseOpts.port}`;
}
function rawHash(data: string | Buffer): `sha256:${string}` {
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
}
describe('GET /file', () => {
let h: Harness;
beforeEach(async () => {
@ -86,6 +90,7 @@ describe('GET /file', () => {
});
expect(res.body.sizeBytes).toBe(12);
expect(res.body.returnedBytes).toBe(12);
expect(res.body.hash).toBe(rawHash('hello\nworld\n'));
});
it('returns the requested line window', async () => {
@ -191,6 +196,65 @@ describe('GET /file', () => {
});
});
describe('GET /file/bytes', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness();
});
afterEach(async () => teardown(h));
it('returns base64 raw bytes and hash for a full-file window', async () => {
const data = Buffer.from([0, 1, 2, 3, 255]);
await fsp.writeFile(path.join(h.workspace, 'bin.dat'), data);
const res = await request(h.app)
.get('/file/bytes?path=bin.dat')
.set('Host', loopbackHost());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
kind: 'file_bytes',
path: 'bin.dat',
offset: 0,
sizeBytes: data.length,
returnedBytes: data.length,
truncated: false,
contentBase64: data.toString('base64'),
hash: rawHash(data),
});
});
it('returns a partial byte window without hash', async () => {
await fsp.writeFile(
path.join(h.workspace, 'window.bin'),
Buffer.from([1, 2, 3, 4, 5]),
);
const res = await request(h.app)
.get('/file/bytes?path=window.bin&offset=1&maxBytes=2')
.set('Host', loopbackHost());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
offset: 1,
returnedBytes: 2,
truncated: true,
contentBase64: Buffer.from([2, 3]).toString('base64'),
});
expect(res.body.hash).toBeUndefined();
});
it('rejects malformed offset and maxBytes with parse_error', async () => {
const badOffset = await request(h.app)
.get('/file/bytes?path=x&offset=-1')
.set('Host', loopbackHost());
expect(badOffset.status).toBe(400);
expect(badOffset.body.errorKind).toBe('parse_error');
const badMax = await request(h.app)
.get('/file/bytes?path=x&maxBytes=999999999')
.set('Host', loopbackHost());
expect(badMax.status).toBe(400);
expect(badMax.body.errorKind).toBe('parse_error');
});
});
describe('GET /stat', () => {
let h: Harness;
beforeEach(async () => {
@ -442,7 +506,7 @@ describe('GET /glob', () => {
});
describe('capability advertisement', () => {
it('advertises workspace_file_read on /capabilities', async () => {
it('advertises workspace file capabilities on /capabilities', async () => {
const h = await makeHarness();
try {
const res = await request(h.app)
@ -450,6 +514,8 @@ describe('capability advertisement', () => {
.set('Host', loopbackHost());
expect(res.status).toBe(200);
expect(res.body.features).toContain('workspace_file_read');
expect(res.body.features).toContain('workspace_file_bytes');
expect(res.body.features).toContain('workspace_file_write');
} finally {
await teardown(h);
}

View file

@ -8,6 +8,7 @@ import * as path from 'node:path';
import type { Application, Request, Response } from 'express';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
import {
MAX_READ_BYTES,
isFsError,
type FsError,
type WorkspaceFileSystemFactory,
@ -35,6 +36,9 @@ export const MAX_LIST_ENTRIES = 2000;
*/
export const MAX_FILE_LINE_LIMIT = 2000;
/** Default byte window for `GET /file/bytes` when `maxBytes` is omitted. */
export const DEFAULT_FILE_BYTES_MAX_BYTES = 64 * 1024;
/**
* Default cap when the caller omits `?maxResults` on `GET /glob`.
* Mirrors the orchestrator's default behavior (no cap) clipped to a
@ -63,7 +67,7 @@ export const MAX_GLOB_MAX_RESULTS = 50_000;
* directly. Both are harmless on the SDK / curl path and
* mandatory for any browser-adjacent client.
*/
function applyReadHeaders(res: Response): void {
export function applyReadHeaders(res: Response): void {
res.set('Cache-Control', 'no-store');
res.set('X-Content-Type-Options', 'nosniff');
}
@ -121,7 +125,7 @@ function parseIntInRange(
if (raw === undefined) return undefined;
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return null;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < min || n > max) return null;
if (!Number.isSafeInteger(n) || n < min || n > max) return null;
return n;
}
@ -254,6 +258,7 @@ async function handleGetFile(
sizeBytes: out.meta.sizeBytes ?? returnedBytes,
returnedBytes,
truncated: out.meta.truncated === true,
hash: out.meta.hash,
matchedIgnore: out.meta.matchedIgnore ?? null,
originalLineCount: out.meta.originalLineCount ?? null,
});
@ -262,6 +267,68 @@ async function handleGetFile(
}
}
async function handleGetFileBytes(
req: Request,
res: Response,
deps: RegisterDeps,
): Promise<void> {
const ROUTE = 'GET /file/bytes';
const factory = getFsFactory(req, res);
if (!factory) return;
const clientId = deps.parseClientId(req, res);
if (clientId === null) return;
const queryPath = requireStringQuery(res, req.query['path'], 'path', ROUTE);
if (queryPath === null) return;
const offset = parseIntInRange(
req.query['offset'],
0,
Number.MAX_SAFE_INTEGER,
);
if (offset === null) {
applyReadHeaders(res);
res.status(400).json({
errorKind: 'parse_error',
error: '`offset` must be a non-negative safe integer',
status: 400,
});
return;
}
const maxBytes = parseIntInRange(req.query['maxBytes'], 1, MAX_READ_BYTES);
if (maxBytes === null) {
applyReadHeaders(res);
res.status(400).json({
errorKind: 'parse_error',
error: `\`maxBytes\` must be a positive integer in [1, ${MAX_READ_BYTES}]`,
status: 400,
});
return;
}
const fs = factory.forRequest({
originatorClientId: clientId ?? undefined,
route: ROUTE,
});
try {
const resolved = await fs.resolve(queryPath, 'read');
const out = await fs.readBytesWindow(resolved, {
offset: offset ?? 0,
maxBytes: maxBytes ?? DEFAULT_FILE_BYTES_MAX_BYTES,
});
applyReadHeaders(res);
res.status(200).json({
kind: 'file_bytes',
path: workspaceRelative(req, resolved),
offset: out.offset,
sizeBytes: out.sizeBytes,
returnedBytes: out.returnedBytes,
truncated: out.truncated,
contentBase64: out.buffer.toString('base64'),
...(out.hash ? { hash: out.hash } : {}),
});
} catch (err) {
sendFsError(res, err, ROUTE);
}
}
async function handleGetStat(
req: Request,
res: Response,
@ -434,7 +501,7 @@ async function handleGetGlob(
* Windows yields backslashes, which would otherwise leak into
* `/file`, `/stat`, `/list`, and `/glob` response paths.
*/
function workspaceRelative(req: Request, resolved: string): string {
export function workspaceRelative(req: Request, resolved: string): string {
const boundWorkspace = (req.app.locals as { boundWorkspace?: string })
.boundWorkspace;
if (!boundWorkspace) {
@ -450,6 +517,7 @@ export function registerWorkspaceFileReadRoutes(
deps: RegisterDeps,
): void {
app.get('/file', (req, res) => handleGetFile(req, res, deps));
app.get('/file/bytes', (req, res) => handleGetFileBytes(req, res, deps));
app.get('/stat', (req, res) => handleGetStat(req, res, deps));
app.get('/list', (req, res) => handleGetList(req, res, deps));
app.get('/glob', (req, res) => handleGetGlob(req, res, deps));

View file

@ -0,0 +1,270 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash, randomBytes } from 'node:crypto';
import { promises as fsp } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import request from 'supertest';
import { createServeApp } from '../server.js';
import {
canonicalizeWorkspace,
createWorkspaceFileSystemFactory,
} from '../fs/index.js';
import type { BridgeEvent } from '../eventBus.js';
import type { ServeOptions } from '../types.js';
const baseOpts: ServeOptions = {
hostname: '127.0.0.1',
port: 4180,
mode: 'http-bridge',
};
interface Harness {
workspace: string;
scratch: string;
events: BridgeEvent[];
app: ReturnType<typeof createServeApp>;
}
async function makeHarness(opts?: {
trusted?: boolean;
token?: string;
}): Promise<Harness> {
const scratch = await fsp.mkdtemp(
path.join(
os.tmpdir(),
`qwen-write-routes-${randomBytes(4).toString('hex')}-`,
),
);
const wsDir = path.join(scratch, 'ws');
await fsp.mkdir(wsDir);
const workspace = canonicalizeWorkspace(wsDir);
const events: BridgeEvent[] = [];
const fsFactory = createWorkspaceFileSystemFactory({
boundWorkspace: workspace,
trusted: opts?.trusted ?? true,
emit: (e) => events.push(e),
});
const app = createServeApp(
{ ...baseOpts, workspace, token: opts?.token },
undefined,
{ fsFactory },
);
return { workspace, scratch, events, app };
}
async function teardown(h: Harness): Promise<void> {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
function loopbackHost(): string {
return `127.0.0.1:${baseOpts.port}`;
}
function rawHash(data: string | Buffer): `sha256:${string}` {
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
}
describe('POST /file/write', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness({ token: 'secret' });
});
afterEach(async () => teardown(h));
it('requires a token even on loopback no-token defaults', async () => {
await teardown(h);
h = await makeHarness();
const res = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.send({ path: 'a.txt', content: 'x', mode: 'create' });
expect(res.status).toBe(401);
expect(res.body.code).toBe('token_required');
});
it('creates a text file with no-store headers', async () => {
const res = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'a.txt', content: 'hello\n', mode: 'create' });
expect(res.status).toBe(201);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.body).toMatchObject({
kind: 'file_write',
path: 'a.txt',
mode: 'create',
created: true,
sizeBytes: 6,
hash: rawHash('hello\n'),
matchedIgnore: null,
});
expect(await fsp.readFile(path.join(h.workspace, 'a.txt'), 'utf-8')).toBe(
'hello\n',
);
});
it('does not overwrite existing files in create mode', async () => {
await fsp.writeFile(path.join(h.workspace, 'a.txt'), 'old');
const res = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'a.txt', content: 'new', mode: 'create' });
expect(res.status).toBe(409);
expect(res.body.errorKind).toBe('file_already_exists');
expect(await fsp.readFile(path.join(h.workspace, 'a.txt'), 'utf-8')).toBe(
'old',
);
});
it('replaces only when expectedHash matches', async () => {
const target = path.join(h.workspace, 'r.txt');
await fsp.writeFile(target, 'old');
const stale = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({
path: 'r.txt',
content: 'new',
mode: 'replace',
expectedHash: rawHash('stale'),
});
expect(stale.status).toBe(409);
expect(stale.body.errorKind).toBe('hash_mismatch');
const ok = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({
path: 'r.txt',
content: 'new',
mode: 'replace',
expectedHash: rawHash('old'),
});
expect(ok.status).toBe(200);
expect(ok.body.hash).toBe(rawHash('new'));
expect(await fsp.readFile(target, 'utf-8')).toBe('new');
});
it('returns parse_error for malformed bodies', async () => {
const res = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'a.txt', content: 'x', mode: 'replace' });
expect(res.status).toBe(400);
expect(res.body.errorKind).toBe('parse_error');
});
it('rejects unknown supplied client ids', async () => {
const res = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.set('X-Qwen-Client-Id', 'unknown-client')
.send({ path: 'a.txt', content: 'x', mode: 'create' });
expect(res.status).toBe(400);
expect(res.body.code).toBe('invalid_client_id');
});
it('rejects untrusted workspace writes', async () => {
await teardown(h);
h = await makeHarness({ trusted: false, token: 'secret' });
const res = await request(h.app)
.post('/file/write')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'a.txt', content: 'x', mode: 'create' });
expect(res.status).toBe(403);
expect(res.body.errorKind).toBe('untrusted_workspace');
});
});
describe('POST /file/edit', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness({ token: 'secret' });
});
afterEach(async () => teardown(h));
it('applies one edit and returns a new hash', async () => {
const target = path.join(h.workspace, 'config.txt');
await fsp.writeFile(target, 'foo=1\nbar=2\n');
const res = await request(h.app)
.post('/file/edit')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({
path: 'config.txt',
oldText: 'foo=1',
newText: 'foo=42',
expectedHash: rawHash('foo=1\nbar=2\n'),
});
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
kind: 'file_edit',
path: 'config.txt',
replacements: 1,
hash: rawHash('foo=42\nbar=2\n'),
});
expect(await fsp.readFile(target, 'utf-8')).toBe('foo=42\nbar=2\n');
});
it('returns typed errors for absent and ambiguous oldText', async () => {
await fsp.writeFile(path.join(h.workspace, 'x.txt'), 'x\nx\n');
const missing = await request(h.app)
.post('/file/edit')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({
path: 'x.txt',
oldText: 'y',
newText: 'z',
expectedHash: rawHash('x\nx\n'),
});
expect(missing.status).toBe(422);
expect(missing.body.errorKind).toBe('text_not_found');
const ambiguous = await request(h.app)
.post('/file/edit')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({
path: 'x.txt',
oldText: 'x',
newText: 'z',
expectedHash: rawHash('x\nx\n'),
});
expect(ambiguous.status).toBe(422);
expect(ambiguous.body.errorKind).toBe('ambiguous_text_match');
});
it('rejects symlink targets after resolve', async () => {
const outside = path.join(h.scratch, 'outside.txt');
await fsp.writeFile(outside, 'foo=1\n');
await fsp.symlink(outside, path.join(h.workspace, 'link.txt'), 'file');
const res = await request(h.app)
.post('/file/edit')
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({
path: 'link.txt',
oldText: 'foo=1',
newText: 'foo=2',
expectedHash: rawHash('foo=1\n'),
});
expect(res.status).toBe(400);
expect(res.body.errorKind).toBe('symlink_escape');
expect(await fsp.readFile(outside, 'utf-8')).toBe('foo=1\n');
});
});

View file

@ -0,0 +1,292 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Application, Request, RequestHandler, Response } from 'express';
import type { HttpAcpBridge } from '../httpAcpBridge.js';
import {
isContentHash,
type ContentHash,
type WorkspaceFileSystemFactory,
type WriteMode,
} from '../fs/index.js';
import {
applyReadHeaders,
sendFsError,
workspaceRelative,
} from './workspaceFileRead.js';
interface RegisterDeps {
bridge: HttpAcpBridge;
mutate: (opts?: { strict?: boolean }) => RequestHandler;
parseClientId: (req: Request, res: Response) => string | undefined | null;
safeBody: (req: Request) => Record<string, unknown>;
}
function getFsFactory(
req: Request,
res: Response,
): WorkspaceFileSystemFactory | null {
const factory = (req.app.locals as { fsFactory?: WorkspaceFileSystemFactory })
.fsFactory;
if (!factory) {
applyReadHeaders(res);
res.status(500).json({
errorKind: 'internal_error',
error: 'workspace filesystem factory is not configured',
status: 500,
});
return null;
}
return factory;
}
function sendParseError(res: Response, _route: string, error: string): null {
applyReadHeaders(res);
res.status(400).json({
errorKind: 'parse_error',
error,
status: 400,
});
return null;
}
function requireBodyString(
body: Record<string, unknown>,
key: string,
res: Response,
route: string,
): string | null {
const value = body[key];
if (typeof value !== 'string' || value.length === 0) {
return sendParseError(res, route, `\`${key}\` must be a non-empty string`);
}
return value;
}
function optionalBoolean(
body: Record<string, unknown>,
key: string,
res: Response,
route: string,
): boolean | undefined | null {
const value = body[key];
if (value === undefined) return undefined;
if (typeof value !== 'boolean') {
return sendParseError(res, route, `\`${key}\` must be a boolean`);
}
return value;
}
function optionalString(
body: Record<string, unknown>,
key: string,
res: Response,
route: string,
): string | undefined | null {
const value = body[key];
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.length === 0) {
return sendParseError(res, route, `\`${key}\` must be a non-empty string`);
}
return value;
}
function optionalLineEnding(
body: Record<string, unknown>,
res: Response,
route: string,
): 'crlf' | 'lf' | undefined | null {
const value = body['lineEnding'];
if (value === undefined) return undefined;
if (value !== 'crlf' && value !== 'lf') {
return sendParseError(res, route, '`lineEnding` must be "lf" or "crlf"');
}
return value;
}
function requiredHash(
body: Record<string, unknown>,
res: Response,
route: string,
): ContentHash | null {
const value = body['expectedHash'];
if (!isContentHash(value)) {
return sendParseError(
res,
route,
'`expectedHash` must match sha256:<64 lowercase hex chars>',
);
}
return value;
}
function optionalHash(
body: Record<string, unknown>,
res: Response,
route: string,
): ContentHash | undefined | null {
const value = body['expectedHash'];
if (value === undefined) return undefined;
if (!isContentHash(value)) {
return sendParseError(
res,
route,
'`expectedHash` must match sha256:<64 lowercase hex chars>',
);
}
return value;
}
function resolveOriginatorClientId(
clientId: string | undefined,
deps: RegisterDeps,
res: Response,
): string | undefined | null {
if (clientId === undefined) return undefined;
if (!deps.bridge.knownClientIds().has(clientId)) {
applyReadHeaders(res);
res.status(400).json({
error: `Client id "${clientId}" is not registered for this workspace`,
code: 'invalid_client_id',
clientId,
});
return null;
}
return clientId;
}
async function handlePostFileWrite(
req: Request,
res: Response,
deps: RegisterDeps,
): Promise<void> {
const ROUTE = 'POST /file/write';
const factory = getFsFactory(req, res);
if (!factory) return;
const body = deps.safeBody(req);
const queryPath = requireBodyString(body, 'path', res, ROUTE);
if (queryPath === null) return;
const content = body['content'];
if (typeof content !== 'string') {
sendParseError(res, ROUTE, '`content` must be a string');
return;
}
const rawMode = body['mode'];
if (rawMode !== 'create' && rawMode !== 'replace') {
sendParseError(res, ROUTE, '`mode` must be "create" or "replace"');
return;
}
const mode: WriteMode = rawMode;
const expectedHash =
mode === 'replace'
? requiredHash(body, res, ROUTE)
: optionalHash(body, res, ROUTE);
if (expectedHash === null) return;
const bom = optionalBoolean(body, 'bom', res, ROUTE);
if (bom === null) return;
const encoding = optionalString(body, 'encoding', res, ROUTE);
if (encoding === null) return;
const lineEnding = optionalLineEnding(body, res, ROUTE);
if (lineEnding === null) return;
const clientId = deps.parseClientId(req, res);
if (clientId === null) return;
const originatorClientId = resolveOriginatorClientId(clientId, deps, res);
if (originatorClientId === null) return;
const fs = factory.forRequest({
originatorClientId,
route: ROUTE,
});
try {
const resolved = await fs.resolve(queryPath, 'write');
const out = await fs.writeTextAtomic(resolved, content, {
mode,
...(expectedHash ? { expectedHash } : {}),
...(bom !== undefined ? { bom } : {}),
...(encoding !== undefined ? { encoding } : {}),
...(lineEnding !== undefined ? { lineEnding } : {}),
});
applyReadHeaders(res);
res.status(out.created ? 201 : 200).json({
kind: 'file_write',
path: workspaceRelative(req, resolved),
mode,
created: out.created,
sizeBytes: out.sizeBytes,
hash: out.hash,
encoding: out.meta.encoding ?? 'utf-8',
bom: out.meta.bom === true,
lineEnding: out.meta.lineEnding,
matchedIgnore: out.meta.matchedIgnore ?? null,
});
} catch (err) {
sendFsError(res, err, ROUTE);
}
}
async function handlePostFileEdit(
req: Request,
res: Response,
deps: RegisterDeps,
): Promise<void> {
const ROUTE = 'POST /file/edit';
const factory = getFsFactory(req, res);
if (!factory) return;
const body = deps.safeBody(req);
const queryPath = requireBodyString(body, 'path', res, ROUTE);
if (queryPath === null) return;
const oldText = body['oldText'];
if (typeof oldText !== 'string') {
sendParseError(res, ROUTE, '`oldText` must be a string');
return;
}
const newText = body['newText'];
if (typeof newText !== 'string') {
sendParseError(res, ROUTE, '`newText` must be a string');
return;
}
const expectedHash = requiredHash(body, res, ROUTE);
if (expectedHash === null) return;
const clientId = deps.parseClientId(req, res);
if (clientId === null) return;
const originatorClientId = resolveOriginatorClientId(clientId, deps, res);
if (originatorClientId === null) return;
const fs = factory.forRequest({
originatorClientId,
route: ROUTE,
});
try {
const resolved = await fs.resolve(queryPath, 'edit');
const out = await fs.editAtomic(resolved, oldText, newText, {
expectedHash,
});
applyReadHeaders(res);
res.status(200).json({
kind: 'file_edit',
path: workspaceRelative(req, resolved),
replacements: 1,
sizeBytes: out.writtenBytes,
hash: out.hash,
encoding: out.meta?.encoding ?? 'utf-8',
bom: out.meta?.bom === true,
lineEnding: out.meta?.lineEnding ?? 'lf',
matchedIgnore: out.meta?.matchedIgnore ?? null,
});
} catch (err) {
sendFsError(res, err, ROUTE);
}
}
export function registerWorkspaceFileWriteRoutes(
app: Application,
deps: RegisterDeps,
): void {
app.post('/file/write', deps.mutate({ strict: true }), (req, res) =>
handlePostFileWrite(req, res, deps),
);
app.post('/file/edit', deps.mutate({ strict: true }), (req, res) =>
handlePostFileEdit(req, res, deps),
);
}

View file

@ -114,6 +114,10 @@ const EXPECTED_STAGE1_FEATURES = [
// Issue #4175 PR 19. Always-on. Daemon exposes the read-only file
// surface: `GET /file`, `GET /list`, `GET /glob`, `GET /stat`.
'workspace_file_read',
// Issue #4175 PR 20. Always-on. Daemon exposes raw byte windows and
// hash-aware text mutation routes behind the strict mutation gate.
'workspace_file_bytes',
'workspace_file_write',
// Issue #4175 PR 21 — auth device-flow surface advertised unconditionally.
'auth_device_flow',
] as const;
@ -3249,6 +3253,9 @@ describe('runQwenServe', () => {
readBytes: async () => {
throw new Error('unreachable');
},
readBytesWindow: async () => {
throw new Error('unreachable');
},
list: async () => {
throw new Error('unreachable');
},
@ -3261,9 +3268,15 @@ describe('runQwenServe', () => {
writeText: async () => {
throw new Error('unreachable');
},
writeTextAtomic: async () => {
throw new Error('unreachable');
},
edit: async () => {
throw new Error('unreachable');
},
editAtomic: async () => {
throw new Error('unreachable');
},
}),
};
const bridge = fakeBridge();

View file

@ -59,6 +59,7 @@ import {
type WorkspaceFileSystemFactory,
} from './fs/index.js';
import { registerWorkspaceFileReadRoutes } from './routes/workspaceFileRead.js';
import { registerWorkspaceFileWriteRoutes } from './routes/workspaceFileWrite.js';
/**
* Build a no-op fs-audit emitter that logs a warning every
@ -619,6 +620,12 @@ export function createServeApp(
registerWorkspaceFileReadRoutes(app, {
parseClientId: parseClientIdHeader,
});
registerWorkspaceFileWriteRoutes(app, {
bridge,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
});
// -- Issue #4175 PR 21 — auth device-flow routes ------------------------

View file

@ -137,6 +137,7 @@ export * from './services/fileDiscoveryService.js';
export * from './services/fileHistoryService.js';
export * from './services/fileReadCache.js';
export * from './services/fileSystemService.js';
export { decodeBufferWithEncodingInfo } from './utils/fileUtils.js';
export * from './services/gitService.js';
export * from './services/gitWorktreeService.js';
export * from './services/sessionRecap.js';

View file

@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs/promises';
import {
StandardFileSystemService,
encodeTextFileContent,
needsUtf8Bom,
resetUtf8BomCache,
detectLineEnding,
@ -141,6 +142,21 @@ describe('StandardFileSystemService', () => {
});
describe('writeTextFile', () => {
it('encodeTextFileContent returns final bytes for UTF-8 and CRLF metadata', () => {
const encoded = encodeTextFileContent('/test/file.txt', 'a\nb\n', {
lineEnding: 'crlf',
});
expect(encoded.toString('utf8')).toBe('a\r\nb\r\n');
});
it('encodeTextFileContent preserves UTF-8 BOM in returned bytes', () => {
const encoded = encodeTextFileContent('/test/file.txt', 'Hello', {
bom: true,
});
expect(Array.from(encoded.subarray(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
expect(encoded.subarray(3).toString('utf8')).toBe('Hello');
});
it('should write file content using fs', async () => {
vi.mocked(fs.writeFile).mockResolvedValue();

View file

@ -167,6 +167,11 @@ export function detectLineEnding(content: string): LineEnding {
return content.includes('\r\n') ? 'crlf' : 'lf';
}
interface PreparedTextFileContent {
data: string | Buffer;
encoding?: BufferEncoding;
}
/**
* Return the BOM byte sequence for a given encoding name, or null if the
* encoding does not use a standard BOM. Used when writing back a file that
@ -192,6 +197,65 @@ function getBOMBytesForEncoding(encoding: string): Buffer | null {
}
}
function prepareTextFileContent(
filePath: string,
content: string,
meta?: ReadTextFileResponse['_meta'] | null,
): PreparedTextFileContent {
const lineEnding = meta?.['lineEnding'] as string | undefined;
const shouldUseCrlf = needsCrlfLineEndings(filePath) || lineEnding === 'crlf';
const normalizedContent = shouldUseCrlf
? ensureCrlfLineEndings(content)
: content;
const bom = meta?.['bom'] ?? (false as boolean);
const encoding = meta?.['encoding'] as string | undefined;
// Check if a non-UTF-8 encoding is specified and supported by iconv-lite
const isNonUtf8Encoding =
encoding &&
!isUtf8CompatibleEncoding(encoding) &&
iconvEncodingExists(encoding);
if (isNonUtf8Encoding) {
// Non-UTF-8 encoding (e.g. GBK, Big5, Shift_JIS, UTF-16LE, UTF-32BE…)
// Use iconv-lite to encode the content. When the file originally had a BOM
// (bom: true), prepend the correct BOM bytes for this encoding so the
// byte-order mark is preserved on write-back.
const encoded = iconvEncode(normalizedContent, encoding);
if (bom) {
const bomBytes = getBOMBytesForEncoding(encoding);
return {
data: bomBytes ? Buffer.concat([bomBytes, encoded]) : encoded,
};
}
return { data: encoded };
}
if (bom) {
// UTF-8 BOM: prepend EF BB BF
// If content already starts with the BOM character, strip it first to avoid double BOM.
const contentWithoutBom =
normalizedContent.charCodeAt(0) === 0xfeff
? normalizedContent.slice(1)
: normalizedContent;
const bomBuffer = Buffer.from([0xef, 0xbb, 0xbf]);
const contentBuffer = Buffer.from(contentWithoutBom, 'utf-8');
return { data: Buffer.concat([bomBuffer, contentBuffer]) };
}
return { data: normalizedContent, encoding: 'utf-8' };
}
export function encodeTextFileContent(
filePath: string,
content: string,
meta?: ReadTextFileResponse['_meta'] | null,
): Buffer {
const prepared = prepareTextFileContent(filePath, content, meta);
if (Buffer.isBuffer(prepared.data)) return prepared.data;
return Buffer.from(prepared.data, prepared.encoding ?? 'utf-8');
}
/**
* Standard file system implementation
*/
@ -215,52 +279,13 @@ export class StandardFileSystemService implements FileSystemService {
params: Omit<WriteTextFileRequest, 'sessionId'>,
): Promise<WriteTextFileResponse> {
const { path: filePath, _meta } = params;
const lineEnding = _meta?.['lineEnding'] as string | undefined;
// Convert LF to CRLF when:
// 1. The file type requires it (e.g. .bat, .cmd on Windows), OR
// 2. The original file used CRLF line endings (preserve original style)
const shouldUseCrlf =
needsCrlfLineEndings(filePath) || lineEnding === 'crlf';
const content = shouldUseCrlf
? ensureCrlfLineEndings(params.content)
: params.content;
const bom = _meta?.['bom'] ?? (false as boolean);
const encoding = _meta?.['encoding'] as string | undefined;
// Check if a non-UTF-8 encoding is specified and supported by iconv-lite
const isNonUtf8Encoding =
encoding &&
!isUtf8CompatibleEncoding(encoding) &&
iconvEncodingExists(encoding);
if (isNonUtf8Encoding) {
// Non-UTF-8 encoding (e.g. GBK, Big5, Shift_JIS, UTF-16LE, UTF-32BE…)
// Use iconv-lite to encode the content. When the file originally had a BOM
// (bom: true), prepend the correct BOM bytes for this encoding so the
// byte-order mark is preserved on write-back.
const encoded = iconvEncode(content, encoding);
if (bom) {
const bomBytes = getBOMBytesForEncoding(encoding);
await atomicWriteFile(
filePath,
bomBytes ? Buffer.concat([bomBytes, encoded]) : encoded,
);
} else {
await atomicWriteFile(filePath, encoded);
}
} else if (bom) {
// UTF-8 BOM: prepend EF BB BF
// If content already starts with the BOM character, strip it first to avoid double BOM.
const normalizedContent =
content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
const bomBuffer = Buffer.from([0xef, 0xbb, 0xbf]);
const contentBuffer = Buffer.from(normalizedContent, 'utf-8');
await atomicWriteFile(
filePath,
Buffer.concat([bomBuffer, contentBuffer]),
);
const prepared = prepareTextFileContent(filePath, params.content, _meta);
if (Buffer.isBuffer(prepared.data)) {
await atomicWriteFile(filePath, prepared.data);
} else {
await atomicWriteFile(filePath, content, { encoding: 'utf-8' });
await atomicWriteFile(filePath, prepared.data, {
encoding: prepared.encoding ?? 'utf-8',
});
}
return { _meta };
}

View file

@ -27,6 +27,7 @@ import {
detectFileType,
processSingleFileContent,
detectBOM,
decodeBufferWithEncodingInfo,
readFileWithEncoding,
readFileWithEncodingInfo,
detectFileEncoding,
@ -491,6 +492,31 @@ describe('fileUtils', () => {
});
describe('readFileWithEncodingInfo', () => {
it('should decode plain UTF-8 buffers without reading from a path', () => {
const result = decodeBufferWithEncodingInfo(
Buffer.from('Hello', 'utf8'),
);
expect(result).toEqual({
content: 'Hello',
encoding: 'utf-8',
bom: false,
});
});
it('should decode UTF-8 BOM buffers without reading from a path', () => {
const result = decodeBufferWithEncodingInfo(
Buffer.concat([
Buffer.from([0xef, 0xbb, 0xbf]),
Buffer.from('Hello', 'utf8'),
]),
);
expect(result).toEqual({
content: 'Hello',
encoding: 'utf-8',
bom: true,
});
});
it('should return bom: false and encoding utf-8 for plain UTF-8 file', async () => {
const filePath = path.join(testDir, 'info-utf8.txt');
await fsPromises.writeFile(filePath, 'Hello', 'utf8');

View file

@ -165,6 +165,49 @@ export interface FileReadResult {
bom: boolean;
}
export function decodeBufferWithEncodingInfo(full: Buffer): FileReadResult {
if (full.length === 0) {
return { content: '', encoding: 'utf-8', bom: false };
}
const bomInfo = detectBOM(full);
if (bomInfo) {
return {
content: decodeBOMBuffer(full, bomInfo),
encoding: bomEncodingToName(bomInfo.encoding),
// Mark bom: true for all Unicode BOM variants (UTF-8/16/32) so that
// the BOM is re-written on save and the file's original format is preserved.
bom: true,
};
}
// No BOM — check if it's valid UTF-8 first (fast path for the common case)
if (isValidUtf8(full)) {
return { content: full.toString('utf8'), encoding: 'utf-8', bom: false };
}
// Not valid UTF-8 — try chardet statistical detection
const detected = detectEncodingFromBuffer(full);
if (detected && !isUtf8CompatibleEncoding(detected)) {
try {
if (iconvEncodingExists(detected)) {
return {
content: iconvDecode(full, detected),
encoding: detected,
bom: false,
};
}
} catch (e) {
debugLogger.warn(
`Failed to decode buffer as ${detected}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
// Final fallback: UTF-8 with replacement characters
return { content: full.toString('utf8'), encoding: 'utf-8', bom: false };
}
/**
* Internal helper: decode a buffer given a BOMInfo.
* Returns the decoded string for each supported BOM encoding.
@ -222,44 +265,7 @@ export async function readFileWithEncodingInfo(
): Promise<FileReadResult> {
// Read the file once; detect BOM and decode from the single buffer.
const full = await fs.promises.readFile(filePath);
if (full.length === 0) return { content: '', encoding: 'utf-8', bom: false };
const bomInfo = detectBOM(full);
if (bomInfo) {
return {
content: decodeBOMBuffer(full, bomInfo),
encoding: bomEncodingToName(bomInfo.encoding),
// Mark bom: true for all Unicode BOM variants (UTF-8/16/32) so that
// the BOM is re-written on save and the file's original format is preserved.
bom: true,
};
}
// No BOM — check if it's valid UTF-8 first (fast path for the common case)
if (isValidUtf8(full)) {
return { content: full.toString('utf8'), encoding: 'utf-8', bom: false };
}
// Not valid UTF-8 — try chardet statistical detection
const detected = detectEncodingFromBuffer(full);
if (detected && !isUtf8CompatibleEncoding(detected)) {
try {
if (iconvEncodingExists(detected)) {
return {
content: iconvDecode(full, detected),
encoding: detected,
bom: false,
};
}
} catch (e) {
debugLogger.warn(
`Failed to decode file ${filePath} as ${detected}: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
// Final fallback: UTF-8 with replacement characters
return { content: full.toString('utf8'), encoding: 'utf-8', bom: false };
return decodeBufferWithEncodingInfo(full);
}
/**

View file

@ -21,6 +21,12 @@ import type {
DaemonSessionSummary,
DaemonSessionSupportedCommandsStatus,
DaemonUpdateAgentRequest,
DaemonWorkspaceFile,
DaemonWorkspaceFileBytes,
DaemonWorkspaceFileEditRequest,
DaemonWorkspaceFileEditResult,
DaemonWorkspaceFileWriteRequest,
DaemonWorkspaceFileWriteResult,
DaemonWorkspaceAgentDetail,
DaemonWorkspaceAgentsStatus,
DaemonWorkspaceEnvStatus,
@ -370,6 +376,93 @@ export class DaemonClient {
);
}
// -- Workspace files (issue #4175 PR 20) -------------------------------
async readWorkspaceFile(
filePath: string,
opts: { maxBytes?: number; line?: number; limit?: number } = {},
clientId?: string,
): Promise<DaemonWorkspaceFile> {
const url = new URL(`${this.baseUrl}/file`);
url.searchParams.set('path', filePath);
if (opts.maxBytes !== undefined) {
url.searchParams.set('maxBytes', String(opts.maxBytes));
}
if (opts.line !== undefined) {
url.searchParams.set('line', String(opts.line));
}
if (opts.limit !== undefined) {
url.searchParams.set('limit', String(opts.limit));
}
return await this.fetchWithTimeout(
url.toString(),
{ headers: this.headers({}, clientId) },
async (res) => {
if (!res.ok) throw await this.failOnError(res, 'GET /file');
return (await res.json()) as DaemonWorkspaceFile;
},
);
}
async readWorkspaceFileBytes(
filePath: string,
opts: { offset?: number; maxBytes?: number } = {},
clientId?: string,
): Promise<DaemonWorkspaceFileBytes> {
const url = new URL(`${this.baseUrl}/file/bytes`);
url.searchParams.set('path', filePath);
if (opts.offset !== undefined) {
url.searchParams.set('offset', String(opts.offset));
}
if (opts.maxBytes !== undefined) {
url.searchParams.set('maxBytes', String(opts.maxBytes));
}
return await this.fetchWithTimeout(
url.toString(),
{ headers: this.headers({}, clientId) },
async (res) => {
if (!res.ok) throw await this.failOnError(res, 'GET /file/bytes');
return (await res.json()) as DaemonWorkspaceFileBytes;
},
);
}
async writeWorkspaceFile(
req: DaemonWorkspaceFileWriteRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileWriteResult> {
return await this.fetchWithTimeout(
`${this.baseUrl}/file/write`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
body: JSON.stringify(req),
},
async (res) => {
if (!res.ok) throw await this.failOnError(res, 'POST /file/write');
return (await res.json()) as DaemonWorkspaceFileWriteResult;
},
);
}
async editWorkspaceFile(
req: DaemonWorkspaceFileEditRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileEditResult> {
return await this.fetchWithTimeout(
`${this.baseUrl}/file/edit`,
{
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }, clientId),
body: JSON.stringify(req),
},
async (res) => {
if (!res.ok) throw await this.failOnError(res, 'POST /file/edit');
return (await res.json()) as DaemonWorkspaceFileEditResult;
},
);
}
// -- Workspace memory (issue #4175 PR 16) ------------------------------
/**

View file

@ -39,6 +39,7 @@ export { parseSseStream, SseFramingError } from './sse.js';
export {
DAEMON_ERROR_KINDS,
DaemonCapabilityMissingError,
isDaemonContentHash,
requireWorkspaceCwd,
} from './types.js';
export type {
@ -132,10 +133,17 @@ export type {
DaemonStatus,
DaemonStatusCell,
DaemonUpdateAgentRequest,
DaemonContentHash,
DaemonWorkspaceAgentDetail,
DaemonWorkspaceAgentSummary,
DaemonWorkspaceAgentsStatus,
DaemonWorkspaceEnvStatus,
DaemonWorkspaceFile,
DaemonWorkspaceFileBytes,
DaemonWorkspaceFileEditRequest,
DaemonWorkspaceFileEditResult,
DaemonWorkspaceFileWriteRequest,
DaemonWorkspaceFileWriteResult,
DaemonWorkspaceMcpServerStatus,
DaemonWorkspaceMcpStatus,
DaemonWorkspaceMemoryFile,

View file

@ -411,6 +411,92 @@ export interface DaemonWriteMemoryResult {
changed?: boolean;
}
export type DaemonContentHash = `sha256:${string}`;
const DAEMON_CONTENT_HASH_RE = /^sha256:[0-9a-f]{64}$/;
export function isDaemonContentHash(
value: unknown,
): value is DaemonContentHash {
return typeof value === 'string' && DAEMON_CONTENT_HASH_RE.test(value);
}
export interface DaemonWorkspaceFile {
kind: 'file';
path: string;
content: string;
encoding: string;
bom: boolean;
lineEnding: 'crlf' | 'lf';
sizeBytes: number;
returnedBytes: number;
truncated: boolean;
hash?: DaemonContentHash;
matchedIgnore: 'file' | 'directory' | null;
originalLineCount: number | null;
}
export interface DaemonWorkspaceFileBytes {
kind: 'file_bytes';
path: string;
offset: number;
sizeBytes: number;
returnedBytes: number;
truncated: boolean;
contentBase64: string;
hash?: DaemonContentHash;
}
interface DaemonWorkspaceFileWriteRequestBase {
path: string;
content: string;
bom?: boolean;
encoding?: string;
lineEnding?: 'crlf' | 'lf';
}
export type DaemonWorkspaceFileWriteRequest =
| (DaemonWorkspaceFileWriteRequestBase & {
mode: 'create';
expectedHash?: DaemonContentHash;
})
| (DaemonWorkspaceFileWriteRequestBase & {
mode: 'replace';
expectedHash: DaemonContentHash;
});
export interface DaemonWorkspaceFileEditRequest {
path: string;
oldText: string;
newText: string;
expectedHash: DaemonContentHash;
}
export interface DaemonWorkspaceFileWriteResult {
kind: 'file_write';
path: string;
mode: 'create' | 'replace';
created: boolean;
sizeBytes: number;
hash: DaemonContentHash;
encoding: string;
bom: boolean;
lineEnding: 'crlf' | 'lf';
matchedIgnore: 'file' | 'directory' | null;
}
export interface DaemonWorkspaceFileEditResult {
kind: 'file_edit';
path: string;
replacements: 1;
sizeBytes: number;
hash: DaemonContentHash;
encoding: string;
bom: boolean;
lineEnding: 'crlf' | 'lf';
matchedIgnore: 'file' | 'directory' | null;
}
/**
* Issue #4175 PR 16: subagent CRUD types. `agentType` on the wire is
* the `name` field from the agent's frontmatter (case-insensitive);

View file

@ -12,6 +12,7 @@ export {
DaemonSessionClient,
asKnownDaemonEvent,
createDaemonSessionViewState,
isDaemonContentHash,
isDaemonEventType,
isKnownDaemonEvent,
parseSseStream,
@ -28,6 +29,7 @@ export {
type DaemonClientEvictedData,
type DaemonClientEvictedEvent,
type DaemonClientOptions,
type DaemonContentHash,
type DaemonControlEvent,
type DaemonEvent,
type DaemonEventEnvelope,
@ -66,6 +68,12 @@ export {
type DaemonStatus,
type DaemonStatusCell,
type DaemonWorkspaceEnvStatus,
type DaemonWorkspaceFile,
type DaemonWorkspaceFileBytes,
type DaemonWorkspaceFileEditRequest,
type DaemonWorkspaceFileEditResult,
type DaemonWorkspaceFileWriteRequest,
type DaemonWorkspaceFileWriteResult,
type DaemonWorkspacePreflightStatus,
type DaemonSessionUpdateData,
type DaemonSessionUpdateEvent,

View file

@ -13,6 +13,7 @@ import {
} from '../../src/daemon/DaemonClient.js';
import {
DaemonCapabilityMissingError,
isDaemonContentHash,
requireWorkspaceCwd,
} from '../../src/daemon/types.js';
import type {
@ -139,6 +140,161 @@ describe('DaemonClient', () => {
});
});
describe('workspace file helpers', () => {
it('validates daemon content hashes with the daemon regex', () => {
expect(isDaemonContentHash(`sha256:${'a'.repeat(64)}`)).toBe(true);
expect(isDaemonContentHash(`sha256:${'A'.repeat(64)}`)).toBe(false);
expect(isDaemonContentHash(`sha256:${'a'.repeat(63)}`)).toBe(false);
expect(isDaemonContentHash('md5:' + 'a'.repeat(64))).toBe(false);
expect(isDaemonContentHash(undefined)).toBe(false);
});
it('reads text files with query params and client identity', async () => {
const payload = {
kind: 'file',
path: 'src/a.ts',
content: 'export {}\n',
encoding: 'utf-8',
bom: false,
lineEnding: 'lf',
sizeBytes: 10,
returnedBytes: 10,
truncated: false,
hash: 'sha256:' + 'a'.repeat(64),
matchedIgnore: null,
originalLineCount: null,
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, payload));
const client = new DaemonClient({ baseUrl: 'http://daemon/', fetch });
await expect(
client.readWorkspaceFile('src/a.ts', { line: 2, limit: 3 }, 'client-1'),
).resolves.toEqual(payload);
expect(calls[0]?.method).toBe('GET');
expect(calls[0]?.url).toBe(
'http://daemon/file?path=src%2Fa.ts&line=2&limit=3',
);
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
});
it('reads raw bytes as base64 payloads', async () => {
const payload = {
kind: 'file_bytes',
path: 'bin.dat',
offset: 4,
sizeBytes: 9,
returnedBytes: 2,
truncated: true,
contentBase64: Buffer.from([5, 6]).toString('base64'),
};
const { fetch, calls } = recordingFetch(() => jsonResponse(200, payload));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.readWorkspaceFileBytes('bin.dat', {
offset: 4,
maxBytes: 2,
}),
).resolves.toEqual(payload);
expect(calls[0]?.url).toBe(
'http://daemon/file/bytes?path=bin.dat&offset=4&maxBytes=2',
);
});
it('writes and edits files with JSON bodies and client identity', async () => {
const writeResult = {
kind: 'file_write',
path: 'a.txt',
mode: 'replace',
created: false,
sizeBytes: 3,
hash: 'sha256:' + 'b'.repeat(64),
encoding: 'utf-8',
bom: false,
lineEnding: 'lf',
matchedIgnore: null,
};
const editResult = {
kind: 'file_edit',
path: 'a.txt',
replacements: 1,
sizeBytes: 4,
hash: 'sha256:' + 'c'.repeat(64),
encoding: 'utf-8',
bom: false,
lineEnding: 'lf',
matchedIgnore: null,
};
const { fetch, calls } = recordingFetch((req) => {
if (req.url.endsWith('/file/write')) {
return jsonResponse(200, writeResult);
}
if (req.url.endsWith('/file/edit')) {
return jsonResponse(200, editResult);
}
return jsonResponse(500, { error: 'unexpected' });
});
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.writeWorkspaceFile(
{
path: 'a.txt',
content: 'new',
mode: 'replace',
expectedHash: `sha256:${'a'.repeat(64)}`,
},
'client-1',
),
).resolves.toEqual(writeResult);
await expect(
client.editWorkspaceFile(
{
path: 'a.txt',
oldText: 'new',
newText: 'next',
expectedHash: `sha256:${'b'.repeat(64)}`,
},
'client-1',
),
).resolves.toEqual(editResult);
expect(calls[0]).toMatchObject({
method: 'POST',
url: 'http://daemon/file/write',
body: JSON.stringify({
path: 'a.txt',
content: 'new',
mode: 'replace',
expectedHash: `sha256:${'a'.repeat(64)}`,
}),
});
expect(calls[0]?.headers['content-type']).toBe('application/json');
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
expect(calls[1]).toMatchObject({
method: 'POST',
url: 'http://daemon/file/edit',
});
});
it('preserves structured error bodies for hash conflicts', async () => {
const body = {
errorKind: 'hash_mismatch',
error: 'expected stale, found current',
status: 409,
};
const { fetch } = recordingFetch(() => jsonResponse(409, body));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const err = await client
.writeWorkspaceFile({
path: 'a.txt',
content: 'new',
mode: 'replace',
expectedHash: `sha256:${'a'.repeat(64)}`,
})
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(DaemonHttpError);
expect((err as DaemonHttpError).status).toBe(409);
expect((err as DaemonHttpError).body).toEqual(body);
});
});
describe('read-only status routes', () => {
it('GETs workspace status routes and returns payloads unchanged', async () => {
const mcp: DaemonWorkspaceMcpStatus = {