feat(web-shell): support workspace file uploads (#8874)

* feat: add web shell workspace file uploads

* fix(serve): accept plain string targets in shared atomic publisher (#8874)

* fix(review): bound web shell related paths

* fix(review): address web shell file upload review findings (#8874)

* test(serve): include upload capability in baseline

* fix(review): pin workspace_file_upload in the serve capabilities integration baseline (#8874)

* fix(review): address round-2 web shell file upload review findings (#8874)

* fix(review): address round-3 web shell file upload review findings (#8874)

* fix(review): address round-4 web shell file upload review findings (#8874)

* fix(review): address round-5 web shell file upload review findings (#8874)

* fix(review): address remaining file upload findings (#8874)

* fix(review): address round-6 web shell file upload review findings (#8874)

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
ytahdn 2026-08-13 06:42:07 +00:00 committed by GitHub
parent 3fd40fd065
commit a8bcaefea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 7243 additions and 165 deletions

View file

@ -0,0 +1,310 @@
# Web Shell File Upload
## Problem
The Web Shell composer allows referencing workspace files via `@path/to/file`, but the file must already exist in the workspace. Users frequently need to bring local files (screenshots, data files, configs) into the workspace to reference them in prompts. The current workflow requires manually saving files via the CLI or another tool before the Web Shell can see them.
This feature adds direct file upload from the browser to the workspace:
1. **Drag-and-drop** onto the composer input — uploads to the target workspace root, shows inline progress above the input.
2. **@ panel upload item** — uploads to the currently browsed directory in the @ file picker.
3. After upload, the composer automatically inserts `@filename` so the existing `@` resolver can consume supported files.
## Out of scope
- Multipart form parsing, resumable/chunked uploads, folder upload.
- `expectedHash`-gated writes (CAS): the browser cannot cheaply hash a large file before upload. Can be added later if a client needs it.
- In-place overwrite of existing files via upload: **uploads never overwrite**. The server always resolves an ordinary name conflict by auto-numbering. If in-place replacement or fail-on-conflict behavior is ever needed, it should be added only with a concrete client requirement and an explicit contract.
- ACP-HTTP parity (`_qwen/file/upload`): REST-only for v1, see below.
- Configurable size limit (env/flag): hardcoded constant for now, matching existing limit style.
## Design
### fs layer: new `writeBytesAtomic`
`WorkspaceFileSystem` (`packages/cli/src/serve/fs/workspace-file-system.ts`) has byte **reads** (`readBytes` / `readBytesWindow`) but only text **writes** (`writeTextAtomic` / `writeTextOverwrite` / `writeText` / `edit*`), all of which apply encoding/BOM/line-ending normalization that would corrupt binary content. This feature therefore adds a symmetric binary write method to the interface first:
```typescript
writeBytesAtomic(
p: ResolvedPath,
data: Buffer,
): Promise<{ sizeBytes: number; hash: ContentHash }>;
```
The method is a single-purpose no-clobber create primitive; it cannot modify or replace existing file content. Posture mirrors the existing `writeTextAtomic({ mode: 'create' })` publication semantics:
- Add `MAX_UPLOAD_BYTES = 50 * 1024 * 1024` to `fs/policy.ts` and export it through `fs/index.ts`. `writeBytesAtomic` enforces `enforceWriteSize(data.length, MAX_UPLOAD_BYTES)`; existing text writes continue using the default `MAX_WRITE_BYTES = 5 * 1024 * 1024`. The upload limit is a distinct binary-ingress policy, not an increase to agent text-write limits.
- `writeBytesAtomic` enforces the trust boundary itself with `assertTrustedForIntent(..., 'write')`; HTTP admission is only an early-rejection optimization. It checks the generation guard at entry, again inside the path lock before temp-file publication, and at the existing final publish checkpoint so a draining/removed runtime cannot commit after admission.
- Atomic temp-file + publish: an interrupted or canceled upload never exposes a partial target.
- An existing target throws `FsError('file_already_exists')` (409), including an external writer racing the final no-clobber publication.
- Symlinks at the target are rejected (`symlink_escape`), consistent with the text writes; boundary resolution goes through the existing `resolve(path, 'write')`.
- A new file is created at `0o600` (not umask default).
- The implementation reuses the existing path lock, temp-file reservation, no-clobber create publication, generation guard, audit, and cleanup machinery. Generalize the current atomic publisher to accept an already validated `Buffer`; do not copy a second binary-specific atomic-write implementation. The byte path must not pass through `atomicWriteTextResolvedFile`, whose internal `enforceWriteSize(buf.length)` intentionally applies the 5 MiB text default. Each public write path validates its final byte buffer with its own policy before calling the shared publisher.
### Daemon: new `POST /file/upload` endpoint
Extend `routes/workspace-file-write.ts`, which already owns the workspace file mutation routes and its private `getFsFactory` / `parseClientId` / `resolveOriginatorClientId` machinery. Keeping upload registration there avoids cloning the trust, identity, and workspace-resolution plumbing into a second module.
**Routes** (both behind `deps.mutate({ strict: true })`):
- `POST /file/upload`
- `POST /workspaces/:workspace/file/upload`
Route ownership/scope is identical to `POST /file/write`: workspace-scoped, resolved-runtime. The qualified variant follows the same failure semantics — unknown (including an already removed workspace), untrusted, or non-active workspace states are rejected and never fall back to the primary runtime.
**Request:**
```
Content-Type: application/octet-stream
X-Qwen-Client-Id: <clientId>
Query parameters:
path — target file path (relative to workspace root), required,
encoded by URLSearchParams (filenames are frequently non-ASCII);
the server validates Express's already-decoded req.query.path and
must not call decodeURIComponent again
Body: raw binary bytes
```
**Middleware chain:**
1. `deps.mutate({ strict: true })` — unauthenticated mutations are rejected before any buffering.
2. `fileUploadAdmission` — performs every cheap request-level check before buffering (final-name boundary checks happen in the handler's candidate loop, which runs after buffering):
- Legacy route: verifies the primary workspace is currently trusted through an injected `isWorkspaceTrusted()` dependency.
- Qualified route: `resolveWorkspaceRuntimeFromParam``requireTrustedWorkspaceRuntime``setWorkspaceRouteContext`. Unknown (including an already removed workspace), untrusted, or draining workspaces stop here and never fall back to the primary runtime.
- Requires `Content-Type: application/octet-stream`; otherwise returns `{ errorKind: 'unsupported_media_type', error: 'File uploads require application/octet-stream', status: 415 }` with status 415.
- Rejects missing/invalid `path` and a requested basename over `MAX_UPLOAD_FILENAME_BYTES` with a standard `parse_error` envelope.
- If a valid `Content-Length` is present and exceeds `MAX_UPLOAD_BYTES`, returns the upload-specific 413 immediately. The raw parser remains authoritative for chunked bodies and clients that omit or understate the header.
- Runs `parseClientId` and `resolveOriginatorClientId` against the selected runtime's bridge. An invalid client id is rejected before buffering.
- Splits `path` into directory + basename, resolves the directory with `fs.resolve(dir, 'write')`, and verifies it is an existing directory with `fs.stat`. Traversal, parent-link escapes, missing/non-directory parents, and other boundary failures are therefore rejected before buffering. The requested final name itself is resolved per candidate in the handler's loop after buffering; an escaping final-component symlink surfaces as the loop's boundary error.
- Stores the requested basename, resolved parent directory, route name, and the per-request fs instance in a private request context for the handler; the handler does not resolve the parent directory again.
3. `fileUploadConcurrencyGate` — admits at most `MAX_CONCURRENT_UPLOADS = 4` requests across the legacy and qualified routes. `createServeApp` creates one shared gate and injects it into both route registrations. A saturated gate returns 429 with `Retry-After: 1` before body parsing. Before the upload handler starts, response `finish` or `close` releases the slot; after the handler starts, the slot remains held until the handler settles so disconnecting clients cannot bypass the memory bound.
4. `fileUploadBodyParser` — wraps `express.raw({ type: 'application/octet-stream', limit: MAX_UPLOAD_BYTES })`. The numeric fs policy constant is the single source of truth for both parser and write limits. Its callback intercepts body-parser `status === 413` and returns the upload-specific `file_too_large` envelope below; other errors call `next(err)`. This prevents the global JSON parser error handler from incorrectly reporting the existing 10 MB JSON limit.
5. Handler: normalizes an absent parsed body for a valid zero-length request to `Buffer.alloc(0)`, takes the admitted request-scoped fs instance, then executes the name-allocation flow below.
Path traversal and symlink escape are blocked by the same `fs.resolve` boundary guards as `/file/write`.
**Name allocation:** `WorkspaceFileSystem` only exposes no-clobber byte creation. The route owns the upload-specific naming policy:
- Try the requested path first, then numbered candidates on `file_already_exists`. Insert ` (N)` before the final extension: `report.pdf → report (1).pdf → report (2).pdf`; no extension: `README → README (1)`; a dotfile with no further extension stays whole: `.env → .env (1)`. The loop makes 1000 attempts total — the requested name plus ` (1)` through ` (999)` — then returns `file_already_exists` if every name is occupied.
- Every numbered candidate is built under the captured resolved directory and independently passes through `fs.resolve(candidate, 'write')`. If resolution produces a different path, that candidate is occupied by an in-workspace symlink and the route continues numbering without calling `writeBytesAtomic`. The no-clobber fs primitive makes concurrent uploads and external writers safe without relying on a route-level lock: if the name is already occupied by any entry, the route tries the next candidate. Boundary and I/O errors stop the loop.
- A route-local `MAX_UPLOAD_FILENAME_BYTES = 255` is the v1 upload filename policy cap, chosen to avoid `ENAMETOOLONG` on common POSIX filesystems; it is not claimed as a complete cross-platform filename validator. When a suffix would exceed the cap, trim only the stem on a Unicode code-point boundary until `stem + suffix + extension` fits; never trim the extension or split a UTF-8 sequence. If the suffix and extension alone cannot fit, return `parse_error`. Platform-specific restrictions such as Windows reserved names remain fs errors from `resolve`/publication.
**Response:** uploads always create, so the response is always 201. `path` is the final server-confirmed path — a numbered candidate when the requested name was occupied — and clients must use it (not the requested path) for the `@` reference.
```json
{
"kind": "file_upload",
"path": "relative/path/to/report (1).pdf",
"sizeBytes": 12345,
"hash": "sha256:<64 lowercase hex>"
}
```
The response does not include a redundant `renamed` flag. A client that needs to show an auto-numbering hint compares the requested `path` with the returned `path`.
Filesystem and upload-specific validation errors use `{ errorKind, error, status, ...details }`: `file_already_exists` 409 when the numbered-candidate cap is exhausted, `parse_error` 400, `unsupported_media_type` 415, `path_outside_workspace` / `symlink_escape` 400, `untrusted_workspace` / `permission_denied` 403, and upload-specific 413:
```json
{
"errorKind": "file_too_large",
"error": "Request body too large (max 50 MiB)",
"status": 413,
"maxBytes": 52428800
}
```
The admission check and route-level raw-parser wrapper both emit this response because parser failures occur before the handler and cannot pass through `sendFsError`. Authentication, client-id, and workspace-runtime failures keep their existing daemon envelopes; the SDK's existing `DaemonHttpError` already preserves their status and parsed response body. This route does not duplicate shared validation helpers merely to rename `code` to `errorKind`.
When all upload slots are occupied, the concurrency gate returns:
```json
{
"errorKind": "upload_busy",
"error": "Too many uploads in progress",
"status": 429,
"retryAfterSeconds": 1
}
```
**Limits:** `MAX_UPLOAD_BYTES` is the shared hardcoded 50 MiB policy constant; no separate string-valued route constant or env/flag configurability without a driver. It is sized for screenshots, data files, and configs. Keeping the parser and fs boundary on the same numeric constant prevents requests from being fully buffered under one limit and rejected later under another. Because `express.raw` holds the complete body in memory, relying on the listener's default 256-connection cap would permit roughly 12.5 GiB of upload buffers. The shared four-slot gate instead bounds upload-body buffering to roughly 200 MiB plus normal framework overhead. Make the limit configurable or replace buffering with a streaming fs primitive only if production measurements require a different throughput/memory tradeoff.
**Capability and limit discovery:** add `workspace_file_upload: { since: 'v1' }` in `capabilities.ts` — convention is new route contract = new tag (same split as `workspace_file_bytes` from `workspace_file_read`). Also add optional `maxWorkspaceFileUploadBytes` to `DaemonCapabilitiesLimits` and advertise `MAX_UPLOAD_BYTES` when the feature is present. Web Shell checks this value before sending and falls back to 50 MiB only if a capability-compatible daemon omits it. Older daemons without the feature tag hide the entry points and return 404 if called directly. A secondary-workspace target additionally requires `workspace_qualified_rest_core`; update that capability's route description to include file upload.
**ACP-HTTP: out of scope for v1.** `/file/write` also exists as `_qwen/file/write` on the ACP-HTTP surface, but `/file/upload` is REST-only: the Web Shell (the only v1 consumer) talks REST directly, and the ACP-HTTP JSON wire cannot carry raw binary. No entries in `acpRouteTable.ts` / `dispatch.ts`; a base64 `_qwen/file/upload` can follow if a non-browser ACP client ever needs it.
**Telemetry:** add the `/workspace/file/upload` suffix to the POST allowlist in `server/telemetry.ts` (normalized from `/workspaces/:workspace/file/upload`, next to the existing `/workspace/file/write` entry), otherwise latency lands in the unknown bucket.
### SDK: `uploadWorkspaceFile()` on both client classes
Follows the existing request-object signature style (`writeWorkspaceFile(req, clientId?)`). Qualified access goes through the existing `client.workspaceById()` / `workspaceByCwd()` selectors — **no** `uploadWorkspaceQualifiedFile` on `DaemonClient`.
```typescript
interface DaemonWorkspaceFileUploadRequest {
path: string;
data: ArrayBuffer | Uint8Array | Blob;
signal?: AbortSignal;
/** Omitted inherits the client's default; 0 disables the timeout. */
timeoutMs?: number;
/** Browser-only: requesting progress without XMLHttpRequest is an error. */
onProgress?: (event: { loaded: number; total: number }) => void;
}
interface DaemonWorkspaceFileUploadResult {
kind: 'file_upload';
path: string;
sizeBytes: number;
hash: DaemonContentHash;
}
// DaemonClient (legacy-primary), mirrors writeWorkspaceFile
async uploadWorkspaceFile(
req: DaemonWorkspaceFileUploadRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileUploadResult>;
// WorkspaceDaemonClient (workspace-qualified), mirrors its writeWorkspaceFile
async uploadWorkspaceFile(
req: DaemonWorkspaceFileUploadRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileUploadResult>;
```
Both delegate to one shared internal raw-POST helper on `DaemonClient`, parameterized by URL + route name, the same pairing `WorkspaceDaemonClient` already uses (`/file/write``POST /workspaces/:workspace/file/write`). This keeps authentication headers, timeout/abort composition, response parsing, and `DaemonHttpError` construction in one place. Build the URL with `URL.searchParams.set('path', req.path)`; do not pre-encode `path` with `encodeURIComponent`.
Transport is `XMLHttpRequest` when `onProgress` is provided (`fetch` exposes no upload progress), plain `fetch` otherwise. `onProgress` is explicitly browser-only: if `XMLHttpRequest` is unavailable, fail before sending rather than silently losing progress. Both paths honor `signal`, use the same authentication/client-id headers and `failOnError` response shape, and apply `timeoutMs`. Omission inherits the client's existing timeout; `0` explicitly disables it. The Web Shell passes `timeoutMs: 0` because its per-item `AbortController` owns cancellation and a valid 50 MiB upload can exceed the SDK's general 30-second default.
### Web Shell: target workspace resolution
The Web Shell is multi-workspace, so uploads must use the same target as the composer's existing file actions. Do not add a second voice-style resolver:
- When `useComposerCore` has `workspace` and `atWorkspaceCwd`, use `workspace.client.workspaceByCwd(atWorkspaceCwd).uploadWorkspaceFile(...)`, exactly as its qualified `listDirectory` / `globWorkspace` actions do today. This includes a primary workspace addressed through the qualified route.
- Only the existing legacy composer path with no `atWorkspaceCwd` uses `workspace.client.uploadWorkspaceFile(...)`; a modern multi-workspace composer with a missing cwd is unsupported rather than silently targeting the primary workspace.
- Drag-and-drop and the @ panel entry share the selected client. The @ panel additionally supplies a directory within that workspace.
- A legacy target requires `workspace_file_upload`; a cwd-qualified target requires both `workspace_file_upload` and `workspace_qualified_rest_core`. The selected workspace must also be present exactly once and trusted in the capabilities snapshot. Otherwise hide both upload entry points.
- Host control: the web-shell accepts an optional `fileUploadEnabled` prop (threaded through the customization context). It is an additional gate, not a replacement for the capability: `fileUploadEnabled === false` force-hides both entry points even when the daemon advertises `workspace_file_upload`, while `true`/omitted still requires the capability (and the trust / qualified-route checks above). It never bypasses the capability.
### Upload versus `@` consumption
The upload endpoint is format-agnostic workspace storage. A successful upload guarantees that the bytes were created atomically at the returned path; it does **not** guarantee that every model/provider can inline or interpret that file. The automatically inserted reference continues through the existing `@` resolver and inherits its limits:
- Images use the existing image pipeline and its source/decoding limits.
- PDFs use the existing PDF extraction/rendering behavior.
- Text files remain subject to model context and text-processing limits.
- Unsupported binary formats and oversized non-image binaries may upload successfully but fail when the prompt tries to consume them.
The Web Shell does not duplicate file sniffing or maintain a second format-support matrix. User-facing copy says the file was uploaded and referenced, not that every model can read every format; any consumption failure comes from the existing resolver. E2E verification must exercise actual prompt consumption for a supported text file and image, not only file existence and inserted composer text.
### Web Shell: `useFileUpload` hook
New hook at `packages/web-shell/client/hooks/useFileUpload.ts`:
```typescript
interface UseFileUploadOptions {
/** Structural client; both daemon client classes satisfy it. */
client: FileUploadClient | undefined;
maxBytes: number;
targetKey: string;
}
interface FileUploadItem {
id: string;
file: File;
targetPath: string; // requested relative path in the target workspace
status: 'pending' | 'uploading' | 'done' | 'error';
progress: number; // 01
/** Locally classified failures; the render site localizes them. */
errorCode?: 'tooLarge' | 'noDaemon' | 'tooManyFiles';
error?: string; // raw failure message (server-side errors)
resultPath?: string; // server-confirmed final path
/** Set on a `tooManyFiles` notice row: how many files were not queued. */
skippedCount?: number;
}
interface UseFileUploadReturn {
uploads: FileUploadItem[];
/** True while any item is pending or in flight; gates composer submit. */
isBusy: boolean;
uploadFiles: (
files: File[],
targetDir: string,
onUploaded?: (path: string) => void,
) => number; // returns how many files were actually queued
removeUpload: (id: string) => void; // aborts the in-flight request too
}
```
Occupied names are always auto-numbered; safety-boundary failures, candidate exhaustion, and I/O failures still produce an error row. `uploadFiles` stores `onUploaded` with each queued item and invokes it exactly once per successful upload with the server-confirmed final path. A batch accepts at most `MAX_FILES_PER_BATCH = 100` files; the overflow is not queued and surfaces as a single `tooManyFiles` notice row carrying the skipped count, so unbounded drops cannot keep the strictly-sequential queue busy for hours.
- Done rows display the final file name. If `resultPath !== targetPath`, they additionally show a short auto-numbering hint so the user sees why the name differs from what they dropped.
- Callers pre-flight the target-specific capability set via the same `workspace.capabilities?.features` snapshot `VoiceButton` uses and hide the entry points when unsupported.
- Before queueing, reject files larger than `capabilities.limits.maxWorkspaceFileUploadBytes` (50 MiB fallback) locally with a clear error; the server-side 413 remains authoritative.
- Process each `uploadFiles` batch sequentially in selection order: one item is `uploading`, the rest remain `pending`. A failed or canceled item does not block later items. This keeps browser/daemon memory bounded and makes `@` insertion order deterministic; add concurrency only if measurements justify it later.
- Removing a pending/uploading row aborts the client request. Atomic writes guarantee that a partial target is never exposed, but cancellation is best effort: if the server has already received the body and begun publishing, the complete file may still be written.
- When `targetKey` changes or the hook unmounts, abort and clear the queue. Ignore any late completion from the previous generation so an upload started for workspace A cannot insert a path into workspace B's composer.
### Web Shell: composer drag-and-drop
1. Listen for `dragenter` / `dragover` / `dragleave` / `drop` on the composer surface. A batch containing only supported images remains on the existing image-attachment path; ordinary files and mixed batches use workspace upload, so one drop is never handled by both paths.
2. For workspace-upload batches, extract `event.dataTransfer.files` and call `uploadFiles(files, '.', onUploaded)` (target workspace root).
3. Progress UI: a thin strip above the composer input surface, one row per queued/uploading/error file — filename, state or percentage, and remove/cancel action. State text is not color-only, and icon actions have localized accessible names. Completed rows disappear after three seconds; error rows remain until dismissed.
4. On completion, add an inline `kind: 'file'` composer tag whose serialized value is `@<finalPath>`, escaping through the same pipeline existing file items use (`escapeAtReferenceText(sanitizeInsertText(path))`) — screenshot filenames with spaces and non-ASCII characters are common.
### Web Shell: @ panel upload item
In `useAtMentionMenu.ts`'s `createFileProvider`, when the files provider is in directory-browse mode:
1. Prepend a synthetic `AtMentionItem` with a new `kind: 'upload'` at the top of the list. Its label/description use the existing i18n catalog. It appears only when the entry query is empty (the same condition that shows `currentDirectoryItem`) so it does not pollute filtered results, participates in normal keyboard navigation, and is subject to the existing `ITEM_LIMIT` slice.
2. Selecting it removes the mention text that opened the panel, snapshots `fileDirectoryRef.current`, invokes an `onUploadRequest(targetDir, restoreQuery)` callback wired in from the composer as a `UseAtMentionMenuOptions` field, and closes the menu. If upload availability vanished while the menu was open (stale item), the accept closes the menu without removing the text. The callback synchronously stores `targetDir` and the current upload `targetKey`, keeps the `restoreQuery` callback, then calls a mounted hidden `<input type="file" multiple>` so the browser treats it as part of the user gesture. This is UI behavior, not a workspace filesystem action, so it does not belong on `AtMentionWorkspaceActions`; the menu hook stays free of `DaemonClient` concerns.
3. The input's change handler uploads the selected files to the captured `targetDir` only if the captured `targetKey` is still current, then clears `input.value` so choosing the same file again fires a new change event. A native `cancel` listener (React only wires `cancel` on `<dialog>`, and the event does not bubble) invokes the stored `restoreQuery` so a canceled picker gives the removed mention text back.
4. On success, add the same inline file tag used by an existing file-menu selection, directly from the server-confirmed response path. No new cache invalidation API is needed: selecting the upload item closes the menu, and `close()` already replaces `builtinCacheRef.current`; the next open fetches a fresh directory listing.
Note: uploads to git-ignored paths succeed but remain invisible in the @ listing (`entries.filter((entry) => !entry.ignored)`); the inserted `@` reference still resolves.
### Data flow summary
```
Browser file
↓ (drag-drop or @ panel upload item)
useFileUpload.uploadFiles() [target workspace resolved]
↓ (XHR with progress, or fetch)
DaemonClient / WorkspaceDaemonClient.uploadWorkspaceFile()
POST /file/upload?path=... (raw octet-stream body)
↓ mutate gate → workspace/trust/client/metadata admission → concurrency gate → raw parser
route candidate loop
↓ fs.resolve(candidate, 'write') → fs.writeBytesAtomic (no-clobber create)
201 with confirmed (possibly renumbered) path
addTags([{ kind: 'file', serialized: '@<finalPath>' }], { placement: 'inline' })
```
## Security and failure behavior
- The route reuses the strict mutation gate, workspace trust checks, client identity validation, and `fs.resolve` boundary guards from the `workspace-file-write.ts` machinery.
- **Uploads never overwrite existing entries.** Occupied names, including in-workspace final-component symlinks, are auto-numbered without writing through them. No path in this feature modifies or replaces existing content — the candidate loop only ever creates new files. Escaping links and other safety-boundary failures, candidate exhaustion, and I/O failures remain errors.
- Binary writes are atomic (temp + publish): network failures and cancels never expose a partial target. A late client cancellation may still result in the complete file being published.
- The upload is not idempotent: if the server publishes the file but the response is lost, the client cannot know whether creation succeeded. The Web Shell does not automatically retry a request after bytes were sent; a manual retry may intentionally create a numbered copy.
- Wrong Content-Type → 415 before buffering. Zero-byte `application/octet-stream` uploads are valid and produce the SHA-256 of an empty buffer.
- Oversized bodies → the route-specific 413 `file_too_large` envelope; handler/fs failures use `sendFsError`; path escape or an escaping/racing symlink → 400; untrusted workspace → 403.
- The qualified route never falls back to the primary runtime for unknown (including already removed), untrusted, or draining workspaces.
- Upload-body memory is bounded by `MAX_UPLOAD_BYTES × MAX_CONCURRENT_UPLOADS` (about 200 MiB with the v1 constants); auth, workspace resolution, trust, Content-Type, Content-Length, metadata, client identity, and initial path-boundary resolution all run before the concurrency gate and body buffering.
## Implementation order
1. **fs layer** — add and export `MAX_UPLOAD_BYTES`, generalize the existing atomic publication internals around an already validated `Buffer`, then add the trust- and generation-gated no-clobber `writeBytesAtomic` create primitive with colocated tests. Preserve the existing 5 MiB text-write policy.
2. **Daemon route** — extend `routes/workspace-file-write.ts` with pre-buffer admission, one shared four-slot concurrency gate injected into legacy + qualified registrations, the route-owned numbered-candidate loop, upload-specific raw-parser errors, capability tag, and telemetry entry; keep route tests colocated in `workspace-file-write.test.ts`, with qualified cases in `workspace-qualified-rest.test.ts`.
3. **SDK** — add `maxWorkspaceFileUploadBytes` capability typing plus `uploadWorkspaceFile()` on `DaemonClient` and `WorkspaceDaemonClient` with the shared raw-POST helper, browser progress, timeout, and abort support, tests.
4. **`useFileUpload` hook** — standalone sequential queue with local size preflight and target-generation cancellation, testable without UI.
5. **Composer drag-and-drop** — hook + progress strip + reference insertion.
6. **@ panel upload item** — synthetic item + target-directory callback wiring; reuse the menu's existing cache reset on close.
## Test plan
- **fs layer**: byte-identical round-trip of binary fixtures, including an empty buffer; a payload greater than 5 MiB and at most `MAX_UPLOAD_BYTES` succeeds, proving the text-write default is not applied to the byte path; a direct `writeBytesAtomic` call above `MAX_UPLOAD_BYTES` fails with `file_too_large`; existing text writes above `MAX_WRITE_BYTES` remain rejected. Trust/generation: a direct untrusted call fails with `untrusted_workspace`; a generation closed after method entry but before publication leaves no target. Atomicity: interrupted write leaves no partial target; an external create racing the no-clobber publish still yields `file_already_exists`; symlink target rejected; new file created at `0o600`.
- **Daemon route**: correct bytes written with correct hash and size; zero-byte octet-stream → 201 with the empty-buffer hash; wrong or missing Content-Type → the exact 415 `unsupported_media_type` envelope before buffering; an upload greater than 5 MiB and at most `MAX_UPLOAD_BYTES` succeeds; an oversized declared `Content-Length` is rejected immediately, while a chunked or understated body above `MAX_UPLOAD_BYTES` is rejected by the raw parser before the handler/fs write; both use the exact upload-specific 413 envelope (`errorKind`, `status`, and `maxBytes` included, with no "10 MB" message). Missing/invalid `path`, a requested basename over 255 UTF-8 bytes, invalid client id, missing/non-directory parents, and boundary escapes are rejected before buffering. Paths containing spaces, non-ASCII, `%`, and `#` decode exactly once; a name occupied by a file, directory, or in-workspace final-component symlink → 201 with a numbered `path`, with no write through the existing entry; an escaping symlink remains a boundary error. Numbering preserves the final extension, handles no-extension and dotfile names, skips taken candidates, trims a long Unicode stem to the 255-byte policy cap, and fails at the 1000-candidate cap; auto-numbering never modifies the requested target; concurrent same-name uploads land on distinct candidates. Four admitted uploads may buffer concurrently across both route forms; a fifth receives the exact 429 `upload_busy` response and `Retry-After`, and disconnect/parser-error paths release their slot. The response has no derived `renamed` flag. Capability tag and `limits.maxWorkspaceFileUploadBytes` are advertised. Qualified route: untrusted, unknown (including already removed), and draining workspaces are rejected before buffering and never fall back to the primary runtime.
- **SDK**: progress callbacks fire in a browser; requesting progress without `XMLHttpRequest` fails before sending; omitted timeout inherits the client default, `timeoutMs: 0` disables it, and an explicit timeout or abort signal cancels the request; filesystem errors expose `errorKind` while other daemon errors preserve their existing parsed bodies; both legacy-primary and workspace-qualified clients.
- **Web Shell hook/UI**: a file above the advertised limit is rejected without an HTTP request; a batch above 100 files queues the first 100 and renders one `tooManyFiles` notice row with the skipped count; a batch runs one request at a time in selection order; failure/cancel does not block the next item; removing a pending item prevents it from starting; a late response after abort does not invoke `onUploaded`; changing the target workspace aborts and clears the old queue and ignores late completions; each successful final path creates exactly one inline file tag; removing the last tag restores the placeholder; completed rows disappear after three seconds. Pure supported-image drops stay on the image-attachment path, while ordinary files and mixed batches upload without leaving drag-active styling behind.
- **Web Shell E2E**: drag a file onto the composer → progress strip appears above the input surface → file exists in the workspace → an inline file tag appears (include filenames with spaces/non-ASCII and a literal `%` to cover escaping); drop a file whose requested name is occupied, including by an in-workspace symlink → upload succeeds as `name (1).ext` with an auto-numbering hint derived from the differing paths, the existing entry untouched, and the tag uses the final name; batch drop preserves upload/tag order. @ panel: browse into a nested directory, select upload, choose a file → the trigger `@` is removed, the captured directory receives the file, and an inline file tag appears; reopening the menu fetches a fresh listing without a public cache API; selecting the same local file twice still fires two uploads. Entry points are hidden when either the upload capability or the required qualified-route capability is absent. Submit prompts that reference one uploaded text file and one uploaded image and verify the existing resolver supplies their content; an unsupported/oversized binary surfaces the resolver's existing readable error rather than being described as universally consumable.

View file

@ -40,7 +40,7 @@ That text-read capability slice covers direct `read_file` plus the shared pre-re
| File | Purpose |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paths.ts` | `canonicalizeWorkspace`, `resolveWithinWorkspace`, `hasSuspiciousPathPattern`, branded `ResolvedPath`, `Intent` union (`read \| write \| list \| stat \| glob`). |
| `policy.ts` | `MAX_READ_BYTES`, `MAX_TEXT_SCAN_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. |
| `policy.ts` | `MAX_READ_BYTES`, `MAX_TEXT_SCAN_BYTES`, `MAX_WRITE_BYTES`, `MAX_UPLOAD_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. |
| `audit.ts` | `FS_ACCESS_EVENT_TYPE`, `FS_DENIED_EVENT_TYPE`, `createAuditPublisher`, audit payload types. |
| `errors.ts` | `FsError` class, `isFsError`, `FsErrorKind` union (14 kinds), `FsErrorStatus` union (`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`). |
| `workspace-file-system.ts` | `createWorkspaceFileSystemFactory`, `WorkspaceFileSystem` (the orchestrator that reads/writes/lists), `WriteMode`, `ContentHash`, `FsEntry`, `FsStat`, `ListOptions`, `GlobOptions`, `ReadTextOptions`, `ReadBytesOptions`, `WriteTextAtomicOptions`. |
@ -235,15 +235,16 @@ flowchart LR
## Configuration
| Source | Knob | Effect |
| ------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `WorkspaceFileSystemFactoryDeps.trusted: boolean` | Constructor input | Whether writes are allowed; defaults to `true` from `runQwenServe`, `false` from `createServeApp` (with warning). |
| Constant | `MAX_READ_BYTES = 256 KiB` | Full-snapshot and returned-text cap; larger text requires an explicit window argument. |
| Constant | `MAX_TEXT_SCAN_BYTES = 8 MiB` | Bytes a large-text read may scan to locate a line offset; past it, `file_too_large`. |
| Constant | `MAX_WRITE_BYTES = 5 MiB` | Write cap; sized below `express.json({ limit: '10mb' })`. |
| Constant | `BINARY_PROBE_BYTES = 4096` | Sample size for content-based binary detection. |
| Capability tags | `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write` | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
| Workspace files | `.gitignore`, `.qwenignore` | Ignored paths surface as `ignored: true` from `shouldIgnore`. |
| Source | Knob | Effect |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `WorkspaceFileSystemFactoryDeps.trusted: boolean` | Constructor input | Whether writes are allowed; defaults to `true` from `runQwenServe`, `false` from `createServeApp` (with warning). |
| Constant | `MAX_READ_BYTES = 256 KiB` | Full-snapshot and returned-text cap; larger text requires an explicit window argument. |
| Constant | `MAX_TEXT_SCAN_BYTES = 8 MiB` | Bytes a large-text read may scan to locate a line offset; past it, `file_too_large`. |
| Constant | `MAX_WRITE_BYTES = 5 MiB` | Write cap; sized below `express.json({ limit: '10mb' })`. |
| Constant | `MAX_UPLOAD_BYTES = 50 MiB` | Binary upload cap for `POST /file/upload`; uploads never overwrite and auto-number occupied names. |
| Constant | `BINARY_PROBE_BYTES = 4096` | Sample size for content-based binary detection. |
| Capability tags | `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, `workspace_file_upload` | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
| Workspace files | `.gitignore`, `.qwenignore` | Ignored paths surface as `ignored: true` from `shouldIgnore`. |
## Caveats & Known Limits

View file

@ -122,7 +122,7 @@ Extension management: `extension_management_v2` adds the global `/extensions/*`
Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`. The active and archived export tags are independent from each other and from `session_export` and `workspace_qualified_rest_core`, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only.
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_read_cursor`, `workspace_file_write`, **`workspace_reload`** (conditional).
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_read_cursor`, `workspace_file_write`, `workspace_file_upload`, **`workspace_reload`** (conditional).
MCP guardrails: **`mcp_guardrails`** (`modes: ['warn', 'enforce']`), `mcp_guardrail_events`, `mcp_server_runtime_mutation`, **`mcp_workspace_pool`** (conditional), **`mcp_pool_restart`** (conditional).

View file

@ -195,6 +195,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design
'workspace_mcp_manage', 'mcp_guardrail_events',
'mcp_server_runtime_mutation',
'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write',
'workspace_file_upload',
'session_approval_mode_control', 'workspace_tool_toggle', 'workspace_skill_toggle',
'workspace_skill_batch_toggle',
'workspace_settings', 'workspace_init', 'workspace_mcp_restart',
@ -277,8 +278,13 @@ 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.
`workspace_file_upload` covers `POST /file/upload`, the binary ingress route:
an `application/octet-stream` body capped at `MAX_UPLOAD_BYTES` (50 MiB) is
written into the workspace without ever overwriting — an occupied name is
auto-numbered (`name (1).ext`, `name (2).ext`, ...). It is also a strict
mutation route.
When `workspace_qualified_rest_core` is advertised, the same file surface is also available at `/workspaces/:workspace/file`, `/workspaces/:workspace/file/bytes`, `/workspaces/:workspace/stat`, `/workspaces/:workspace/list`, `/workspaces/:workspace/glob`, `/workspaces/:workspace/file/write`, and `/workspaces/:workspace/file/edit`.
When `workspace_qualified_rest_core` is advertised, the same file surface is also available at `/workspaces/:workspace/file`, `/workspaces/:workspace/file/bytes`, `/workspaces/:workspace/stat`, `/workspaces/:workspace/list`, `/workspaces/:workspace/glob`, `/workspaces/:workspace/file/write`, `/workspaces/:workspace/file/edit`, and `/workspaces/:workspace/file/upload`.
The same tag also exposes workspace-qualified project-agent CRUD at `/workspaces/:workspace/agents` and `/workspaces/:workspace/agents/:agentType`. These plural routes only read or mutate project-level agents for the selected workspace; `global` and `user` scope requests return `400 { code: "global_scope_not_supported_for_workspace_route" }`. Workspace-less `/workspace/agents` routes retain their existing primary-workspace behavior and remain the only REST surface for user-level agent scope.

View file

@ -364,6 +364,7 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_file_bytes',
'workspace_file_read_cursor',
'workspace_file_write',
'workspace_file_upload',
'session_approval_mode_control',
'workspace_tool_toggle',
'workspace_skill_toggle',

View file

@ -979,6 +979,7 @@ describe('createBridgeFileSystemAdapter', () => {
})),
edit: vi.fn(),
editAtomic: vi.fn(),
writeBytesAtomic: vi.fn(),
};
},
};
@ -1030,6 +1031,7 @@ describe('createBridgeFileSystemAdapter', () => {
})),
edit: vi.fn(),
editAtomic: vi.fn(),
writeBytesAtomic: vi.fn(),
};
},
};

View file

@ -162,6 +162,12 @@ export const SERVE_CAPABILITY_REGISTRY = {
// 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' },
// Daemon hosts binary file upload (`POST /file/upload`) behind the strict
// mutation gate. Uploads never overwrite; occupied names auto-number. New
// route contract = new tag (same split as `workspace_file_bytes` from
// `workspace_file_read`). The advertised upload byte cap is surfaced via
// `limits.maxWorkspaceFileUploadBytes`.
workspace_file_upload: { since: 'v1' },
// Daemon hosts the session-level approval-mode
// control route `POST /session/:id/approval-mode` (gated by the
// mutation gate, strict). The route accepts `{mode, persist?}` —
@ -345,8 +351,8 @@ export const SERVE_CAPABILITY_REGISTRY = {
scratch_workspace_registration: { since: 'v1' },
workspace_runtime_removal: { since: 'v1' },
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
// Covers core file/status/permissions/trust/lifecycle/MCP/tool, memory,
// workspace agent CRUD, and persisted session organization surfaces.
// Covers core file read/write/upload, status/permissions/trust/lifecycle/MCP/tool,
// memory, workspace agent CRUD, and persisted session organization surfaces.
// Workspace-qualified settings also require the existing
// `workspace_settings` tag because that surface depends on settings
// persistence. ACP/WebSocket and auth stay outside this core tag;

View file

@ -21,6 +21,7 @@ export {
export {
MAX_READ_BYTES,
MAX_WRITE_BYTES,
MAX_UPLOAD_BYTES,
BINARY_PROBE_BYTES,
assertTrustedForIntent,
detectBinary,

View file

@ -60,6 +60,17 @@ export const MAX_TEXT_SCAN_BYTES = 8 * 1024 * 1024;
*/
export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
/**
* Maximum bytes accepted by the binary upload write path
* (`writeBytesAtomic`). This is a distinct binary-ingress policy, NOT an
* increase to the agent text-write limit: text writes keep
* `MAX_WRITE_BYTES`. Sized for screenshots, data files, and configs that a
* user drags into the Web Shell. The daemon's upload route and the fs
* boundary share this single constant so a request buffered under the parser
* cap is never rejected later under a different limit.
*/
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
/**
* Sample size used for content-based binary detection. Aligned with
* `isBinaryFile` from `packages/core/src/utils/fileUtils.ts:414` so

View file

@ -5,7 +5,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fsp } from 'node:fs';
import { promises as fsp, writeFileSync } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createHash, randomBytes } from 'node:crypto';
@ -2283,6 +2283,218 @@ describe('WorkspaceFileSystem - multi-root workspaces', () => {
});
});
describe('WorkspaceFileSystem - writeBytesAtomic', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness();
});
afterEach(async () => teardown(h));
it('round-trips arbitrary bytes byte-identically', async () => {
const data = randomBytes(4096);
const r = await h.fs.resolve('blob.bin', 'write');
const out = await h.fs.writeBytesAtomic(r, data);
expect(out.sizeBytes).toBe(data.length);
expect(out.hash).toBe(rawHash(data));
expect(await fsp.readFile(r as string)).toEqual(data);
});
it('accepts an empty buffer and hashes it', async () => {
const r = await h.fs.resolve('empty.bin', 'write');
const out = await h.fs.writeBytesAtomic(r, Buffer.alloc(0));
expect(out.sizeBytes).toBe(0);
expect(out.hash).toBe(rawHash(Buffer.alloc(0)));
expect((await fsp.stat(r as string)).size).toBe(0);
});
it('accepts a payload above the 5 MiB text cap (binary policy applies)', async () => {
// 6 MiB > MAX_WRITE_BYTES (5 MiB) but <= MAX_UPLOAD_BYTES (50 MiB):
// proves the byte path does not reuse the text-write default.
const data = Buffer.alloc(6 * 1024 * 1024, 7);
const r = await h.fs.resolve('big.bin', 'write');
const out = await h.fs.writeBytesAtomic(r, data);
expect(out.sizeBytes).toBe(data.length);
expect((await fsp.stat(r as string)).size).toBe(data.length);
});
it('rejects a payload above MAX_UPLOAD_BYTES with file_too_large', async () => {
const data = Buffer.alloc(50 * 1024 * 1024 + 1);
const r = await h.fs.resolve('too-big.bin', 'write');
const err = await h.fs.writeBytesAtomic(r, data).catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_too_large');
await expect(fsp.stat(r as string)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('rejects an existing target with file_already_exists', async () => {
await fsp.writeFile(path.join(h.workspace, 'taken.bin'), 'x');
const r = await h.fs.resolve('taken.bin', 'write');
const err = await h.fs
.writeBytesAtomic(r, Buffer.from('y'))
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_already_exists');
// The existing content is untouched.
expect(
await fsp.readFile(path.join(h.workspace, 'taken.bin'), 'utf-8'),
).toBe('x');
});
it('does not audit a no-clobber collision as fs.denied', async () => {
// Collisions are the upload route's expected candidate-loop control
// flow; monitoring keyed on fs.denied volume must not see them.
await fsp.writeFile(path.join(h.workspace, 'taken.bin'), 'x');
const r = await h.fs.resolve('taken.bin', 'write');
const deniedBefore = h.events.filter(
(e) => e.type === FS_DENIED_EVENT_TYPE,
).length;
await expect(
h.fs.writeBytesAtomic(r, Buffer.from('y')),
).rejects.toMatchObject({ kind: 'file_already_exists' });
expect(
h.events.filter((e) => e.type === FS_DENIED_EVENT_TYPE),
).toHaveLength(deniedBefore);
});
it('rejects an escaping symlink at the boundary', async () => {
const outside = path.join(h.scratch, 'outside.txt');
await fsp.writeFile(outside, 'external');
const link = path.join(h.workspace, 'evil-link');
await fsp.symlink(outside, link);
const err = await h.fs.resolve('evil-link', 'write').catch((e) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('symlink_escape');
expect(await fsp.readFile(outside, 'utf-8')).toBe('external');
});
it('rejects a direct symlink target with symlink_escape', async () => {
// Bypasses `resolve`'s realpath-follow to exercise the defensive
// lstat branch in the no-clobber create path. The route never hands
// `writeBytesAtomic` an in-workspace symlink (it numbers instead), but
// the fs primitive must still refuse to publish through one.
const real = path.join(h.workspace, 'real.bin');
await fsp.writeFile(real, 'orig');
const link = path.join(h.workspace, 'link.bin');
await fsp.symlink(real, link);
const err = await h.fs
.writeBytesAtomic(link as ResolvedPath, Buffer.from('overwrite?'))
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('symlink_escape');
expect(await fsp.readFile(real, 'utf-8')).toBe('orig');
});
it('creates new files at 0o600', async () => {
if (process.platform === 'win32') return;
const r = await h.fs.resolve('secret.bin', 'write');
await h.fs.writeBytesAtomic(r, Buffer.from('s3cret'));
const mode = (await fsp.stat(r as string)).mode & 0o777;
expect(mode).toBe(0o600);
});
it('fails with untrusted_workspace on an untrusted factory', async () => {
await teardown(h);
h = await makeHarness({ trusted: false });
const r = await h.fs.resolve('nope.bin', 'write');
const err = await h.fs
.writeBytesAtomic(r, Buffer.from('x'))
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('untrusted_workspace');
});
it('leaves no target when the generation closes before publication', async () => {
let checks = 0;
await teardown(h);
h = await makeHarness({
generationGuard: {
assertOpen() {
checks += 1;
// resolve() consumes calls 1-2; writeBytesAtomic entry (3) and
// the inside-lock re-check (4) precede the pre-publish checkpoint
// (5). Close at the final publish checkpoint, when the temp file
// already exists.
if (checks === 5) throw new Error('generation closed');
},
},
});
const target = path.join(h.workspace, 'gen-closed.bin');
const r = await h.fs.resolve(target, 'write');
await expect(
h.fs.writeBytesAtomic(r, Buffer.from('must not land')),
).rejects.toThrow('generation closed');
expect(checks).toBe(5);
await expect(fsp.stat(target)).rejects.toMatchObject({ code: 'ENOENT' });
// No stray temp files left behind in the workspace.
const leftover = (await fsp.readdir(h.workspace)).filter((n) =>
n.endsWith('.tmp'),
);
expect(leftover).toEqual([]);
});
it('never clobbers when an external writer wins the pre-publish race', async () => {
let checks = 0;
let target = '';
await teardown(h);
h = await makeHarness({
generationGuard: {
assertOpen() {
checks += 1;
// At the pre-publish checkpoint (after the entry/lock checks and
// the temp write), an external writer grabs the name.
if (checks === 5) writeFileSync(target, 'external');
},
},
});
target = path.join(h.workspace, 'race.bin');
const r = await h.fs.resolve('race.bin', 'write');
const err = await h.fs
.writeBytesAtomic(r, Buffer.from('upload'))
.catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('file_already_exists');
// The external writer's content is untouched.
expect(await fsp.readFile(target, 'utf-8')).toBe('external');
const leftover = (await fsp.readdir(h.workspace)).filter((n) =>
n.endsWith('.tmp'),
);
expect(leftover).toEqual([]);
});
it('records audit access on success and denial', async () => {
const ignore = new Ignore().add(['*.log']);
await teardown(h);
h = await makeHarness({ ignore });
const data = randomBytes(64);
const r = await h.fs.resolve('audit.log', 'write');
await h.fs.writeBytesAtomic(r, data);
const access = h.events.find(
(e) =>
e.type === FS_ACCESS_EVENT_TYPE &&
(e.data as { intent: string }).intent === 'write',
);
expect(access).toBeDefined();
expect((access!.data as { sizeBytes: number }).sizeBytes).toBe(data.length);
expect((access!.data as { matchedIgnore?: string }).matchedIgnore).toBe(
'file',
);
const tooBig = await h.fs.resolve('audit-big.bin', 'write');
await h.fs
.writeBytesAtomic(tooBig, Buffer.alloc(50 * 1024 * 1024 + 1))
.catch(() => {});
const denied = h.events.find(
(e) =>
e.type === FS_DENIED_EVENT_TYPE &&
(e.data as { errorKind: string }).errorKind === 'file_too_large',
);
expect(denied).toBeDefined();
});
});
describe('WorkspaceFileSystem - factory', () => {
it('canonicalizes the workspace once at factory build', async () => {
const scratch = await fsp.mkdtemp(

View file

@ -38,7 +38,12 @@ import {
type AuditPublisher,
createAuditPublisher,
} from './audit.js';
import { FsError, wrapAsFsError, type FsErrorKind } from './errors.js';
import {
FsError,
isFsError,
wrapAsFsError,
type FsErrorKind,
} from './errors.js';
import {
assertCursorMatchesFile,
decodeTextCursor,
@ -55,6 +60,7 @@ import {
BINARY_PROBE_BYTES,
MAX_READ_BYTES,
MAX_TEXT_SCAN_BYTES,
MAX_UPLOAD_BYTES,
assertTrustedForIntent,
enforceReadSize,
enforceWriteSize,
@ -286,6 +292,23 @@ export interface WorkspaceFileSystem {
newText: string,
opts: { expectedHash: ContentHash },
): Promise<WriteOutcome>;
/**
* Single-purpose no-clobber binary create. Writes `data` atomically
* (temp + publish) at `p`; it cannot modify or replace existing file
* content. An existing target (including a final-component symlink)
* throws `file_already_exists` (`symlink_escape` for a symlink). The
* caller is responsible for choosing a free name; the upload route owns
* the numbered-candidate policy; the collision is expected control flow
* there and emits no `fs.denied` audit event. `data` is size-checked
* against `MAX_UPLOAD_BYTES` here the binary-ingress policy, NOT the
* `MAX_WRITE_BYTES` text default. Trust and generation guards are enforced
* at entry, inside the path lock, and at the final publish checkpoint.
* New files are created at `0o600`.
*/
writeBytesAtomic(
p: ResolvedPath,
data: Buffer,
): Promise<{ sizeBytes: number; hash: ContentHash }>;
}
/**
@ -1476,6 +1499,48 @@ class WorkspaceFileSystemImpl implements WorkspaceFileSystem {
}
}
async writeBytesAtomic(
p: ResolvedPath,
data: Buffer,
): Promise<{ sizeBytes: number; hash: ContentHash }> {
const start = performance.now();
try {
this.deps.generationGuard?.assertOpen();
assertTrustedForIntent(this.deps.trusted, 'write');
enforceWriteSize(data.length, MAX_UPLOAD_BYTES);
const out = await this.deps.pathLocks.runExclusive(
p as string,
async () => {
await assertCreateTargetAbsent(p as string);
this.deps.generationGuard?.assertOpen();
const result = await atomicPublishResolvedFile({
target: p,
buf: data,
mode: 'create',
assertGenerationOpen: () => this.deps.generationGuard?.assertOpen(),
});
const verdict = this.ignoreVerdict(p, 'file');
this.deps.audit.recordAccess(this.deps.ctx, {
intent: 'write',
absolute: p,
durationMs: performance.now() - start,
sizeBytes: result.sizeBytes,
matchedIgnore: verdict.ignored ? verdict.category : undefined,
});
return { sizeBytes: result.sizeBytes, hash: result.hash };
},
);
return out;
} catch (err) {
// A no-clobber collision is the upload route's expected candidate-loop
// outcome (it numbers on), not a boundary policy denial; auditing it
// would emit one `fs.denied` per skipped occupied name.
throw this.recordAndWrap(err, 'write', p as string, {
audit: !(isFsError(err) && err.kind === 'file_already_exists'),
});
}
}
/**
* Coerce an arbitrary thrown value into an `FsError`, emit the
* matching `fs.denied` audit event, and return the typed error
@ -1489,7 +1554,12 @@ class WorkspaceFileSystemImpl implements WorkspaceFileSystem {
* - routes can still rely on `instanceof FsError`
* for their `sendFsError` serializer.
*/
private recordAndWrap(err: unknown, intent: Intent, input: string): Error {
private recordAndWrap(
err: unknown,
intent: Intent,
input: string,
opts?: { audit?: boolean },
): Error {
if (
err instanceof Error &&
'code' in err &&
@ -1498,6 +1568,9 @@ class WorkspaceFileSystemImpl implements WorkspaceFileSystem {
return err;
}
const fs = wrapAsFsError(err);
if (opts?.audit === false) {
return fs;
}
this.deps.audit.recordDenied(this.deps.ctx, {
intent,
input,
@ -2177,7 +2250,39 @@ function mergeWriteMeta(
async function atomicWriteTextResolvedFile(
input: AtomicWriteTextInput,
): Promise<AtomicWriteTextOutcome> {
const target = input.target as string;
const buf = await encodeTextFileContentAsync(
input.target,
input.content,
buildWriteMeta(input.meta),
);
// Text writes keep the `MAX_WRITE_BYTES` policy. The byte upload path
// validates its own buffer against `MAX_UPLOAD_BYTES` before calling the
// shared publisher, so the two policies stay distinct.
enforceWriteSize(buf.length);
return atomicPublishResolvedFile({
target: input.target,
buf,
mode: input.mode,
expectedHash: input.expectedHash,
assertGenerationOpen: input.assertGenerationOpen,
});
}
/**
* Shared atomic temp+publish core. `buf` MUST already be size-validated by
* the caller against its own policy (`MAX_WRITE_BYTES` for text,
* `MAX_UPLOAD_BYTES` for binary uploads). This function does not re-check
* size; it only handles the filesystem mechanics: parent validation, temp
* reservation, precondition checks, generation gating, and publication.
*/
async function atomicPublishResolvedFile(input: {
target: string;
buf: Buffer;
mode: WriteMode;
expectedHash?: ContentHash;
assertGenerationOpen?: () => void;
}): Promise<AtomicWriteTextOutcome> {
const target = input.target;
const parent = path.dirname(target);
const parentStat = await fsp.lstat(parent);
// Defense-in-depth against a parent-symlink swap. A full fix requires
@ -2195,24 +2300,34 @@ async function atomicWriteTextResolvedFile(
`parent path is not a directory: ${parent}`,
);
}
const tmpPath = path.join(
parent,
`.${path.basename(target)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
);
const tmpSuffix = `.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
// The temp name is `.<basename><suffix>`. When the target basename is itself
// near the filesystem NAME_MAX (255 bytes) — a 255-byte upload, say — the
// untruncated temp name would exceed it and `open()` would fail ENAMETOOLONG
// before the atomic publish could run. Cap the basename portion (UTF-8-safe).
const tmpNameMaxBytes = 255;
const tmpBaseMaxBytes =
tmpNameMaxBytes - 1 - Buffer.byteLength(tmpSuffix, 'utf-8');
let tmpBase = path.basename(target);
if (Buffer.byteLength(tmpBase, 'utf-8') > tmpBaseMaxBytes) {
tmpBase = safeUtf8Truncate(
Buffer.from(tmpBase, 'utf-8'),
tmpBaseMaxBytes,
).toString('utf-8');
}
const tmpPath = path.join(parent, `.${tmpBase}${tmpSuffix}`);
let tempLive = false;
let tempHandle: Awaited<ReturnType<typeof fsp.open>> | undefined;
let tempStat: Awaited<ReturnType<typeof fsp.lstat>> | undefined;
try {
tempHandle = await reserveTempFile(tmpPath);
tempLive = true;
const encoded = await writeEncodedTextTemp({
targetPath: target,
const written = await writeBufferToTemp({
tmpPath,
content: input.content,
meta: input.meta,
buf: input.buf,
handle: tempHandle,
});
tempStat = encoded.stat;
tempStat = written.stat;
const targetState = await assertAtomicTargetPrecondition({
target,
mode: input.mode,
@ -2231,7 +2346,7 @@ async function atomicWriteTextResolvedFile(
}
tempLive = false;
await fsyncParentDirBestEffort(parent);
return encoded;
return written;
} catch (err) {
await tempHandle?.close().catch(() => undefined);
if (tempLive) {
@ -2251,20 +2366,17 @@ async function reserveTempFile(
return fsp.open(tmpPath, 'wx', 0o600);
}
async function writeEncodedTextTemp(input: {
targetPath: string;
/**
* Write an already-validated buffer to the reserved temp handle and verify
* the handle still names the same regular file. Shared by the text and
* binary publishers; size policy is enforced by the caller, not here.
*/
async function writeBufferToTemp(input: {
tmpPath: string;
content: string;
meta: ReadMeta;
buf: Buffer;
handle: Awaited<ReturnType<typeof fsp.open>>;
}): Promise<AtomicWriteTextOutcome> {
const buf = await encodeTextFileContentAsync(
input.targetPath,
input.content,
buildWriteMeta(input.meta),
);
enforceWriteSize(buf.length);
await input.handle.writeFile(buf);
await input.handle.writeFile(input.buf);
await syncHandleBestEffort(input.handle);
const st = await fsp.lstat(input.tmpPath);
const opened = await input.handle.stat();
@ -2282,7 +2394,7 @@ async function writeEncodedTextTemp(input: {
`temporary path is not a regular file: ${input.tmpPath}`,
);
}
return { sizeBytes: buf.length, hash: hashBuffer(buf), stat: st };
return { sizeBytes: input.buf.length, hash: hashBuffer(input.buf), stat: st };
}
async function assertCreateTargetAbsent(target: string): Promise<void> {

View file

@ -8,6 +8,7 @@ import type { Application } from 'express';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import { getServeProtocolVersions } from '../capabilities.js';
import type { getAdvertisedServeFeatures } from '../capabilities.js';
import { MAX_UPLOAD_BYTES } from '../fs/index.js';
import {
advertisedMaxPendingPromptsPerSession,
advertisedMaxSessions,
@ -70,6 +71,9 @@ export function registerCapabilitiesRoutes(
deps.maxPendingPromptsPerSession,
),
sessionRestoreTimeoutMs: deps.sessionRestoreTimeoutMs,
...(features.includes('workspace_file_upload')
? { maxWorkspaceFileUploadBytes: MAX_UPLOAD_BYTES }
: {}),
...(multiWorkspace
? {
maxSessionsPerWorkspace: advertisedMaxSessions(

View file

@ -665,6 +665,11 @@ describe('capability advertisement', () => {
expect(res.body.features).toContain('workspace_file_read');
expect(res.body.features).toContain('workspace_file_bytes');
expect(res.body.features).toContain('workspace_file_write');
expect(res.body.features).toContain('workspace_file_upload');
// The upload byte cap is advertised alongside the feature.
expect(res.body.limits?.maxWorkspaceFileUploadBytes).toBe(
50 * 1024 * 1024,
);
} finally {
await teardown(h);
}

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type { Application, Request, RequestHandler, Response } from 'express';
import express from 'express';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import {
MAX_UPLOAD_BYTES,
hasSuspiciousPathPattern,
isContentHash,
isFsError,
type ContentHash,
type ResolvedPath,
type WorkspaceFileSystem,
type WorkspaceFileSystemFactory,
type WriteMode,
} from '../fs/index.js';
@ -357,3 +364,473 @@ export function registerWorkspaceQualifiedFileWriteRoutes(
},
);
}
// ---------------------------------------------------------------------------
// File upload (`POST /file/upload`)
//
// Binary ingress into the workspace. Uploads NEVER overwrite: an occupied
// name (file, directory, or in-workspace final-component symlink) is
// auto-numbered (`name (1).ext`, `name (2).ext`, ...). The fs layer only
// exposes a no-clobber byte create; the numbered-candidate policy lives here.
// ---------------------------------------------------------------------------
const MAX_CONCURRENT_UPLOADS = 4;
const MAX_UPLOAD_FILENAME_BYTES = 255;
const NUMBERED_CANDIDATE_CAP = 1000;
interface UploadGateLease {
handlerStarted: boolean;
release(): void;
}
const uploadGateLeases = new WeakMap<Request, UploadGateLease>();
export interface UploadConcurrencyGate {
tryAcquire(): boolean;
release(): void;
}
export function createUploadConcurrencyGate(
max: number = MAX_CONCURRENT_UPLOADS,
): UploadConcurrencyGate {
let active = 0;
return {
tryAcquire() {
if (active >= max) return false;
active += 1;
return true;
},
release() {
if (active > 0) active -= 1;
},
};
}
interface UploadAdmission {
route: string;
fs: WorkspaceFileSystem;
basename: string;
resolvedDir: ResolvedPath;
/**
* The literal request directory admission validated with
* `fs.resolve(dir, 'write')`. Candidates are re-resolved from THIS string,
* never from the canonicalized `resolvedDir`: resolution re-runs
* `hasSuspiciousPathPattern` on its whole input, so canonical segments the
* client never wrote the workspace root's own path when it trips the
* pattern check, or a symlinked parent whose TARGET name does
* (`docs -> aux`) would otherwise fail every upload into the directory.
*/
queryDir: string;
}
const uploadAdmissions = new WeakMap<Request, UploadAdmission>();
function splitStemExtension(basename: string): { stem: string; ext: string } {
// A leading dot (`.env`) is part of the stem, not an extension separator.
const lastDot = basename.lastIndexOf('.');
if (lastDot <= 0) return { stem: basename, ext: '' };
return { stem: basename.slice(0, lastDot), ext: basename.slice(lastDot) };
}
/**
* Trim only the stem on a Unicode code-point boundary until
* `stem + suffix + ext` fits `capBytes`. Never trims the extension and never
* splits a UTF-8 sequence. Returns null when suffix + ext alone cannot fit.
*/
function fitFilenameToByteCap(
stem: string,
suffix: string,
ext: string,
capBytes: number,
): string | null {
if (Buffer.byteLength(suffix + ext, 'utf-8') > capBytes) return null;
let chars = Array.from(stem);
while (Buffer.byteLength(chars.join('') + suffix + ext, 'utf-8') > capBytes) {
if (chars.length === 0) return null;
chars = chars.slice(0, -1);
}
return chars.join('') + suffix + ext;
}
function sendUploadTooLarge(res: Response): void {
applyReadHeaders(res);
res.status(413).json({
errorKind: 'file_too_large',
error: `Request body too large (max ${MAX_UPLOAD_BYTES / (1024 * 1024)} MiB)`,
status: 413,
maxBytes: MAX_UPLOAD_BYTES,
});
}
function fileUploadConcurrencyGate(
gate: UploadConcurrencyGate,
): RequestHandler {
return (req, res, next) => {
if (!gate.tryAcquire()) {
applyReadHeaders(res);
res.status(429).set('Retry-After', '1').json({
errorKind: 'upload_busy',
error: 'Too many uploads in progress',
status: 429,
retryAfterSeconds: 1,
});
return;
}
let released = false;
const release = () => {
if (released) return;
released = true;
gate.release();
res.off('finish', releaseBeforeHandler);
res.off('close', releaseBeforeHandler);
uploadGateLeases.delete(req);
};
const releaseBeforeHandler = () => {
if (!lease.handlerStarted) release();
};
const lease: UploadGateLease = { handlerStarted: false, release };
uploadGateLeases.set(req, lease);
res.once('finish', releaseBeforeHandler);
res.once('close', releaseBeforeHandler);
next();
};
}
// `express.raw` rejects only when the buffered body EXCEEDS `limit`, so a body
// of exactly MAX_UPLOAD_BYTES passes and MAX_UPLOAD_BYTES+1 is rejected with
// the upload-specific 413 envelope below. Using MAX (not MAX+1) keeps both the
// parser and `writeBytesAtomic` on the same cap, so no body can slip past the
// parser and then surface a generic, `maxBytes`-less 413 from the fs layer.
export function fileUploadBodyParser(): RequestHandler {
const raw = express.raw({
type: 'application/octet-stream',
limit: MAX_UPLOAD_BYTES,
// The endpoint contract is raw octets: decoding a Content-Encoding would
// publish bytes the client never sent (and hash/size of the decoded form).
inflate: false,
});
return (req, res, next) => {
raw(req, res, (err?: unknown) => {
if (err) {
const status = (err as { status?: number }).status;
if (status === 413) {
sendUploadTooLarge(res);
return;
}
if (status === 415) {
applyReadHeaders(res);
res.status(415).json({
errorKind: 'unsupported_media_type',
error: 'File uploads do not support encoded request bodies',
status: 415,
});
return;
}
// A client aborting mid-body is routine for large uploads; the gate
// slot is already released by the pre-handler response-close
// listener, so the abort must not surface as an unhandled error.
if ((err as { code?: string }).code === 'ECONNABORTED' || req.aborted) {
return;
}
next(err);
return;
}
next();
});
};
}
function fileUploadAdmission(
deps: RegisterDeps & { workspaceRegistry?: WorkspaceRegistry },
opts: {
qualified: boolean;
isWorkspaceTrusted?: () => boolean;
},
): RequestHandler {
return (req, res, next) => {
void (async () => {
const ROUTE = opts.qualified
? 'POST /workspaces/:workspace/file/upload'
: 'POST /file/upload';
try {
if (opts.qualified) {
const registry = deps.workspaceRegistry;
if (!registry) {
throw new Error('workspace registry is not configured');
}
const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res);
if (!runtime) return;
if (!requireTrustedWorkspaceRuntime(runtime, res)) return;
setWorkspaceRouteContext(req, {
runtime,
routePrefix: 'POST /workspaces/:workspace',
});
} else if (opts.isWorkspaceTrusted?.() === false) {
applyReadHeaders(res);
res.status(403).json({
errorKind: 'untrusted_workspace',
error: 'workspace is not trusted; write operations are forbidden',
status: 403,
});
return;
}
const contentType = (req.headers['content-type'] ?? '')
.split(';')[0]
.trim()
.toLowerCase();
if (contentType !== 'application/octet-stream') {
applyReadHeaders(res);
res.status(415).json({
errorKind: 'unsupported_media_type',
error: 'File uploads require application/octet-stream',
status: 415,
});
return;
}
const queryPath = req.query['path'];
if (typeof queryPath !== 'string' || queryPath.length === 0) {
sendParseError(res, ROUTE, '`path` query parameter is required');
return;
}
const dir = path.dirname(queryPath);
const basename = path.basename(queryPath);
// `path.dirname`/`path.basename` silently normalize a trailing slash,
// so a directory-shaped path must be rejected before they run.
if (
queryPath.endsWith('/') ||
basename.length === 0 ||
basename === '.' ||
basename === '..'
) {
sendParseError(res, ROUTE, '`path` must name a file');
return;
}
// Reject statically detectable bad names before taking a gate slot
// and buffering the body; fs.resolve would throw on them anyway.
if (queryPath.includes('\0')) {
sendParseError(res, ROUTE, 'path must not contain null bytes');
return;
}
if (hasSuspiciousPathPattern(basename)) {
sendParseError(res, ROUTE, 'filename contains a suspicious pattern');
return;
}
if (Buffer.byteLength(basename, 'utf-8') > MAX_UPLOAD_FILENAME_BYTES) {
sendParseError(
res,
ROUTE,
`filename exceeds ${MAX_UPLOAD_FILENAME_BYTES} bytes`,
);
return;
}
const contentLength = req.headers['content-length'];
if (contentLength !== undefined) {
const declared = Number(contentLength);
if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
sendUploadTooLarge(res);
return;
}
}
const clientId = deps.parseClientId(req, res);
if (clientId === null) return;
const originatorClientId = resolveOriginatorClientId(
clientId,
deps,
res,
req,
);
if (originatorClientId === null) return;
const factory = getFsFactory(req, res);
if (!factory) return;
const fs = factory.forRequest({
originatorClientId,
route: ROUTE,
});
const resolvedDir = await fs.resolve(dir, 'write');
let dirStat;
try {
dirStat = await fs.stat(resolvedDir);
} catch (err) {
if (isFsError(err) && err.kind === 'path_not_found') {
sendParseError(res, ROUTE, 'parent directory does not exist');
return;
}
throw err;
}
if (dirStat.kind !== 'directory') {
sendParseError(res, ROUTE, 'parent path is not a directory');
return;
}
uploadAdmissions.set(req, {
route: ROUTE,
fs,
basename,
resolvedDir,
queryDir: dir,
});
next();
} catch (err) {
sendFsError(
res,
err,
opts.qualified
? 'POST /workspaces/:workspace/file/upload'
: 'POST /file/upload',
);
}
})();
};
}
async function handlePostFileUpload(
req: Request,
res: Response,
): Promise<void> {
const admission = uploadAdmissions.get(req);
if (!admission) {
applyReadHeaders(res);
res.status(500).json({
errorKind: 'internal_error',
error: 'upload admission context is missing',
status: 500,
});
return;
}
const { route, fs, basename, resolvedDir, queryDir } = admission;
const { stem, ext } = splitStemExtension(basename);
const lease = uploadGateLeases.get(req);
if (lease) lease.handlerStarted = true;
try {
// A client disconnect during the async admission window lets the body
// parser continue with `req.body === undefined`; writing anyway would
// publish a phantom 0-byte file nobody requested.
if (req.aborted || res.closed) return;
const body = req.body;
const data =
body === undefined || body === null ? Buffer.alloc(0) : (body as Buffer);
for (let n = 0; n < NUMBERED_CANDIDATE_CAP; n++) {
const candidateBasename =
n === 0
? basename
: fitFilenameToByteCap(
stem,
` (${n})`,
ext,
MAX_UPLOAD_FILENAME_BYTES,
);
if (candidateBasename === null) {
sendParseError(
res,
route,
`filename cannot fit within ${MAX_UPLOAD_FILENAME_BYTES} bytes`,
);
return;
}
const candidateAbs = path.join(resolvedDir as string, candidateBasename);
let resolved: ResolvedPath;
try {
resolved = await fs.resolve(
path.join(queryDir, candidateBasename),
'write',
);
} catch (err) {
// A symlink CYCLE occupying the candidate (ELOOP) is merely an
// occupied name — number on like the other occupied cases. Boundary
// escapes and other resolution failures stop the loop.
if (
isFsError(err) &&
err.kind === 'symlink_escape' &&
(err.cause as NodeJS.ErrnoException | undefined)?.code === 'ELOOP'
) {
continue;
}
sendFsError(res, err, route);
return;
}
if ((resolved as string) !== candidateAbs) {
// An in-workspace symlink already occupies this name; number on.
continue;
}
try {
const out = await fs.writeBytesAtomic(resolved, data);
applyReadHeaders(res);
res.status(201).json({
kind: 'file_upload',
path: workspaceRelative(req, resolved as string),
sizeBytes: out.sizeBytes,
hash: out.hash,
});
return;
} catch (err) {
if (isFsError(err) && err.kind === 'file_already_exists') {
continue;
}
sendFsError(res, err, route);
return;
}
}
applyReadHeaders(res);
res.status(409).json({
errorKind: 'file_already_exists',
error: `could not allocate a free filename for "${basename}"`,
status: 409,
});
} catch (err) {
sendFsError(res, err, route);
} finally {
uploadAdmissions.delete(req);
lease?.release();
}
}
export interface FileUploadLegacyDeps extends RegisterDeps {
uploadGate: UploadConcurrencyGate;
isWorkspaceTrusted?: () => boolean;
}
export interface FileUploadQualifiedDeps extends RegisterDeps {
uploadGate: UploadConcurrencyGate;
workspaceRegistry: WorkspaceRegistry;
}
export function registerWorkspaceFileUploadRoutes(
app: Application,
deps: FileUploadLegacyDeps,
): void {
app.post(
'/file/upload',
deps.mutate({ strict: true }),
fileUploadAdmission(deps, {
qualified: false,
isWorkspaceTrusted: deps.isWorkspaceTrusted,
}),
fileUploadConcurrencyGate(deps.uploadGate),
fileUploadBodyParser(),
(req, res) => {
void handlePostFileUpload(req, res);
},
);
}
export function registerWorkspaceQualifiedFileUploadRoutes(
app: Application,
deps: FileUploadQualifiedDeps,
): void {
app.post(
'/workspaces/:workspace/file/upload',
deps.mutate({ strict: true }),
fileUploadAdmission(deps, { qualified: true }),
fileUploadConcurrencyGate(deps.uploadGate),
fileUploadBodyParser(),
(req, res) => {
void handlePostFileUpload(req, res);
},
);
}

View file

@ -468,6 +468,8 @@ const EXPECTED_STAGE1_FEATURES = [
'workspace_file_bytes',
'workspace_file_read_cursor',
'workspace_file_write',
// Binary file upload (never overwrites; auto-numbers occupied names).
'workspace_file_upload',
// Mutation control routes (approval mode, workspace tool/skill toggles,
// init scaffold, and MCP server restart).
'session_approval_mode_control',

View file

@ -98,8 +98,11 @@ import {
registerWorkspaceQualifiedFileReadRoutes,
} from './routes/workspace-file-read.js';
import {
createUploadConcurrencyGate,
registerWorkspaceFileWriteRoutes,
registerWorkspaceFileUploadRoutes,
registerWorkspaceQualifiedFileWriteRoutes,
registerWorkspaceQualifiedFileUploadRoutes,
} from './routes/workspace-file-write.js';
import { registerWorkspaceSetupGithubRoutes } from './routes/workspace-setup-github.js';
import {
@ -2080,6 +2083,27 @@ export function createServeApp(
safeBody,
workspaceRegistry,
});
// One shared four-slot gate bounds upload-body buffering across both the
// legacy and workspace-qualified upload routes.
const uploadGate = createUploadConcurrencyGate();
registerWorkspaceFileUploadRoutes(app, {
bridge: primaryBridge,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
uploadGate,
...(primaryRuntimeTrustAuthoritative
? { isWorkspaceTrusted: isPrimaryWorkspaceTrusted }
: {}),
});
registerWorkspaceQualifiedFileUploadRoutes(app, {
bridge: primaryBridge,
mutate,
parseClientId: parseClientIdHeader,
safeBody,
uploadGate,
workspaceRegistry,
});
registerWorkspaceSetupGithubRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,

View file

@ -387,6 +387,24 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
}
});
it('normalizes the plural workspace upload route to a stable route label', () => {
const mw = daemonTelemetryMiddleware(() => '/ws');
const res = mockRes(200);
mw(
mockReq('POST', '/workspaces/ws-secondary/file/upload'),
res,
vi.fn() as unknown as NextFunction,
);
res.emit('finish');
expect(coreMocks.withDaemonRequestSpan).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'POST',
route: 'POST /workspace/file/upload',
}),
expect.any(Function),
);
});
it('attributes plural workspace voice requests to the selected workspace', () => {
const mw = daemonTelemetryMiddleware(() => '/workspace/secondary');
for (const [method, path, route] of [

View file

@ -556,6 +556,7 @@ export function resolveDaemonTelemetryRoute(
suffix === '/workspace/reload' ||
suffix === '/workspace/file/write' ||
suffix === '/workspace/file/edit' ||
suffix === '/workspace/file/upload' ||
suffix === '/workspace/mcp/servers' ||
suffix === '/workspace/memory' ||
suffix === '/workspace/agents' ||

View file

@ -464,6 +464,8 @@ export interface CapabilitiesEnvelope {
maxSessionsPerWorkspace?: number | null;
maxTotalSessions?: number | null;
sessionRestoreTimeoutMs?: number;
/** Present when `workspace_file_upload` is advertised. */
maxWorkspaceFileUploadBytes?: number;
};
/**
* Language codes accepted by `POST /session/:id/language`.

View file

@ -218,6 +218,7 @@ async function makeHarness(opts?: {
secondaryDirName?: string;
token?: string;
persistSetting?: boolean;
primaryWriteHold?: { hold: Promise<void>; onWriteStart?: () => void };
}) {
const scratch = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-workspace-qualified-rest-'),
@ -231,11 +232,40 @@ async function makeHarness(opts?: {
await fsp.writeFile(path.join(primaryCwd, 'target.txt'), 'primary');
await fsp.writeFile(path.join(secondaryCwd, 'target.txt'), 'secondary');
const primaryFsFactory = createWorkspaceFileSystemFactory({
const primaryFsFactoryBase = createWorkspaceFileSystemFactory({
boundWorkspaces: [primaryCwd],
trusted: true,
emit: () => {},
});
// A Proxy (not a spread) so prototype methods like resolve/stat are
// preserved; only writeBytesAtomic is intercepted to hold the gate slot.
const primaryFsFactory = opts?.primaryWriteHold
? {
assertCanWrite: () => {},
forRequest: (
ctx: Parameters<typeof primaryFsFactoryBase.forRequest>[0],
) => {
const realFs = primaryFsFactoryBase.forRequest(ctx);
return new Proxy(realFs, {
get(target, prop, receiver) {
if (prop === 'writeBytesAtomic') {
return (
p: Parameters<typeof realFs.writeBytesAtomic>[0],
data: Buffer,
) => {
opts.primaryWriteHold?.onWriteStart?.();
return opts.primaryWriteHold!.hold.then(() =>
target.writeBytesAtomic(p, data),
);
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
},
}
: primaryFsFactoryBase;
const secondaryFsFactory = createWorkspaceFileSystemFactory({
boundWorkspaces: [secondaryCwd],
trusted: true,
@ -799,6 +829,193 @@ describe('workspace-qualified core REST', () => {
}
});
it('routes workspace-qualified file uploads and never falls back to primary', async () => {
const h = await makeHarness({ token: 'secret' });
try {
const data = Buffer.from([1, 2, 3, 4]);
const res = await request(h.app)
.post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/upload`)
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'blob.bin' })
.send(data);
expect(res.status).toBe(201);
expect(res.body.path).toBe('blob.bin');
// Landed in the SECONDARY workspace, not the primary.
await expect(
fsp.readFile(path.join(h.secondaryCwd, 'blob.bin')),
).resolves.toEqual(data);
await expect(
fsp.stat(path.join(h.primaryCwd, 'blob.bin')),
).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
// Untrusted secondary: 403, and nothing is written to primary either.
const untrusted = await makeHarness({
secondaryTrusted: false,
token: 'secret',
});
try {
const res = await request(untrusted.app)
.post(
`/workspaces/${encodeURIComponent(untrusted.secondaryId)}/file/upload`,
)
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'blocked.bin' })
.send(Buffer.from('x'));
expect(res.status).toBe(403);
expect(res.body.code).toBe('untrusted_workspace');
await expect(
fsp.stat(path.join(untrusted.primaryCwd, 'blocked.bin')),
).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await fsp.rm(untrusted.scratch, { recursive: true, force: true });
}
});
it('uploads into a workspace whose root path trips the suspicious-pattern check', async () => {
// A canonical root with a trailing-dot segment matches
// hasSuspiciousPathPattern; candidates must re-resolve from the
// workspace-relative admission dir rather than the absolute root.
const h = await makeHarness({
token: 'secret',
secondaryDirName: 'my proj.',
});
try {
const res = await request(h.app)
.post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/upload`)
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'report.txt' })
.send(Buffer.from('hi'));
expect(res.status).toBe(201);
expect(res.body.path).toBe('report.txt');
await expect(
fsp.readFile(path.join(h.secondaryCwd, 'report.txt'), 'utf-8'),
).resolves.toBe('hi');
} finally {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
});
it('shares one upload gate between legacy and qualified routes', async () => {
let started = 0;
let releaseWrites: () => void = () => {};
const hold = new Promise<void>((resolve) => {
releaseWrites = resolve;
});
const h = await makeHarness({
token: 'secret',
primaryWriteHold: { hold, onWriteStart: () => (started += 1) },
});
try {
const legacyUpload = (name: string) =>
request(h.app)
.post('/file/upload')
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: name })
.send(Buffer.from('x'));
const inFlight = [
legacyUpload('a.bin'),
legacyUpload('b.bin'),
legacyUpload('c.bin'),
legacyUpload('d.bin'),
];
// Supertest Tests are lazy thenables — attach a catch to actually
// send each request without awaiting it (they hang in the held write).
inFlight.forEach((p) => void p.catch(() => {}));
await vi.waitFor(() => {
expect(started).toBe(4);
});
// All four gate slots are held through the LEGACY route; a qualified
// upload must draw from the same shared gate and be turned away.
const qualified = await request(h.app)
.post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/upload`)
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'q.bin' })
.send(Buffer.from('x'));
expect(qualified.status).toBe(429);
expect(qualified.body).toMatchObject({
errorKind: 'upload_busy',
status: 429,
});
releaseWrites();
const results = await Promise.all(inFlight);
for (const res of results) {
expect(res.status).toBe(201);
}
} finally {
releaseWrites();
await fsp.rm(h.scratch, { recursive: true, force: true });
}
});
it('rejects qualified uploads for unknown, draining, and already removed workspaces', async () => {
const h = await makeHarness({ token: 'secret' });
try {
const unknown = await request(h.app)
.post('/workspaces/does-not-exist/file/upload')
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'a.bin' })
.send(Buffer.from('x'));
expect(unknown.status).toBe(400);
expect(unknown.body.code).toBe('workspace_mismatch');
await expect(
fsp.stat(path.join(h.primaryCwd, 'a.bin')),
).rejects.toMatchObject({ code: 'ENOENT' });
const secondaryRuntime = h.workspaceRegistry.getByWorkspaceId(
h.secondaryId,
);
expect(secondaryRuntime).toBeDefined();
expect(h.workspaceRegistry.beginDrain(secondaryRuntime!)).toBe(true);
const draining = await request(h.app)
.post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/upload`)
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'b.bin' })
.send(Buffer.from('x'));
expect(draining.status).toBe(503);
expect(draining.body.code).toBe('workspace_runtime_unavailable');
await expect(
fsp.stat(path.join(h.primaryCwd, 'b.bin')),
).rejects.toMatchObject({ code: 'ENOENT' });
h.workspaceRegistry.completeDrain(secondaryRuntime!);
const removed = await request(h.app)
.post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/upload`)
.set('Authorization', 'Bearer secret')
.set('Host', host())
.set('Content-Type', 'application/octet-stream')
.query({ path: 'c.bin' })
.send(Buffer.from('x'));
expect(removed.status).toBe(400);
expect(removed.body.code).toBe('workspace_mismatch');
await expect(
fsp.stat(path.join(h.primaryCwd, 'c.bin')),
).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
});
it('routes workspace-qualified lifecycle mutations and trust-gates them', async () => {
const h = await makeHarness({ token: 'secret' });
try {

View file

@ -50,11 +50,10 @@ export const useEffortCommand = (
// for future sessions, but say it won't take effect until thinking is
// re-enabled.
if (addItem) {
const feedbackItem: HistoryItemWithoutId & Record<string, unknown> =
{
type: MessageType.INFO,
text: formatEffortChangeMessage(config, effort),
};
const feedbackItem: HistoryItemWithoutId & Record<string, unknown> = {
type: MessageType.INFO,
text: formatEffortChangeMessage(config, effort),
};
addItem(feedbackItem, Date.now());
config.getChatRecordingService?.()?.recordSlashCommand({
phase: 'result',

View file

@ -81,7 +81,11 @@ const rootDir = join(__dirname, '..');
// Bumped from 184KB to 185KB for the Live Voice lifecycle helpers on both
// daemon client classes.
// Bumped from 185KB to 186KB for daemon-owned mid-turn message APIs.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 186 * 1024;
// Bumped from 186KB to 188KB for the workspace file-upload surface
// (`uploadWorkspaceFile` + XHR progress) on both daemon client classes.
// Bumped from 188KB to 189KB for the session reasoning-effort config option
// APIs merged in from main.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 189 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't

View file

@ -73,6 +73,8 @@ import type {
DaemonWorkspaceFileBytes,
DaemonWorkspaceFileEditRequest,
DaemonWorkspaceFileEditResult,
DaemonWorkspaceFileUploadRequest,
DaemonWorkspaceFileUploadResult,
DaemonWorkspaceFileWriteRequest,
DaemonWorkspaceFileWriteResult,
DaemonWorkspaceAgentDetail,
@ -1901,6 +1903,177 @@ export class DaemonClient {
);
}
/**
* Upload binary bytes to the workspace. Shared raw-POST core used by both
* the legacy-primary `uploadWorkspaceFile` and the workspace-qualified
* variant, parameterized by URL path + route label. Keeps auth headers,
* timeout/abort composition, progress transport, and `DaemonHttpError`
* construction in one place.
*
* Uses `XMLHttpRequest` when `req.onProgress` is provided (`fetch` exposes
* no upload progress); plain `fetch` otherwise. Progress is browser-only:
* requesting it where `XMLHttpRequest` is unavailable fails before sending.
*
* @internal
*/
async uploadFileToPath(
uploadPath: string,
label: string,
req: DaemonWorkspaceFileUploadRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileUploadResult> {
const target = new URL(`${this.baseUrl}${uploadPath}`);
target.searchParams.set('path', req.path);
const url = target.toString();
const headers = this.headers(
{ 'Content-Type': 'application/octet-stream' },
clientId,
);
if (req.onProgress) {
return await this.uploadWithProgress(url, label, req, headers);
}
return await this.fetchWithTimeout(
url,
{
method: 'POST',
headers,
body: req.data,
...(req.signal ? { signal: req.signal } : {}),
},
async (res) => {
if (!res.ok) throw await this.failOnError(res, label);
const text = await res.text();
let body: unknown;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = text;
}
// Match the XHR path's parse-then-shape-check so the two transports
// fail identically on malformed 2xx bodies.
if (!body || typeof body !== 'object' || !('path' in body)) {
throw new Error(`${label}: invalid upload response body`);
}
return body as DaemonWorkspaceFileUploadResult;
},
req.timeoutMs,
'rest',
);
}
private async uploadWithProgress(
url: string,
label: string,
req: DaemonWorkspaceFileUploadRequest,
headers: Record<string, string>,
): Promise<DaemonWorkspaceFileUploadResult> {
if (typeof XMLHttpRequest === 'undefined') {
throw new Error(
`${label}: upload progress requires XMLHttpRequest (browser only)`,
);
}
let effectiveTimeoutMs = this.fetchTimeoutMs;
if (
req.timeoutMs !== undefined &&
Number.isFinite(req.timeoutMs) &&
req.timeoutMs >= 0
) {
effectiveTimeoutMs = req.timeoutMs;
}
const onProgress = req.onProgress;
return await new Promise<DaemonWorkspaceFileUploadResult>(
(resolve, reject) => {
if (req.signal?.aborted) {
reject(
req.signal.reason ??
new DOMException('The operation was aborted.', 'AbortError'),
);
return;
}
const xhr = new XMLHttpRequest();
let abortListener: (() => void) | undefined;
// Detach the abort listener once the request settles so a long-lived
// signal does not retain a reference to this XHR after completion.
const cleanup = () => {
if (abortListener && req.signal) {
req.signal.removeEventListener('abort', abortListener);
}
};
xhr.open('POST', url);
for (const [name, value] of Object.entries(headers)) {
xhr.setRequestHeader(name, value);
}
if (effectiveTimeoutMs > 0) xhr.timeout = effectiveTimeoutMs;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
onProgress({ loaded: event.loaded, total: event.total });
}
};
xhr.onload = () => {
cleanup();
let body: unknown;
try {
body = xhr.responseText ? JSON.parse(xhr.responseText) : undefined;
} catch {
body = xhr.responseText;
}
if (xhr.status >= 200 && xhr.status < 300) {
// The fetch path rejects non-JSON 2xx bodies (`res.json()`
// throws); match it so the two transports fail identically.
if (!body || typeof body !== 'object' || !('path' in body)) {
reject(new Error(`${label}: invalid upload response body`));
return;
}
resolve(body as DaemonWorkspaceFileUploadResult);
return;
}
const detail =
body && typeof body === 'object' && 'error' in body
? String((body as { error: unknown }).error)
: `HTTP ${xhr.status}`;
reject(new DaemonHttpError(xhr.status, body, `${label}: ${detail}`));
};
xhr.onerror = () => {
cleanup();
reject(new Error(`${label}: network request failed`));
};
xhr.ontimeout = () => {
cleanup();
reject(new DOMException('timeout', 'TimeoutError'));
};
xhr.onabort = () => {
cleanup();
reject(
req.signal?.reason ??
new DOMException('The operation was aborted.', 'AbortError'),
);
};
if (req.signal) {
abortListener = () => xhr.abort();
req.signal.addEventListener('abort', abortListener, { once: true });
}
try {
xhr.send(req.data as XMLHttpRequestBodyInit);
} catch (error) {
cleanup();
reject(error);
}
},
);
}
async uploadWorkspaceFile(
req: DaemonWorkspaceFileUploadRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileUploadResult> {
return await this.uploadFileToPath(
'/file/upload',
'POST /file/upload',
req,
clientId,
);
}
// -- Workspace memory (workspace memory/agents) ------------------------------
/**
@ -5908,6 +6081,18 @@ export class WorkspaceDaemonClient {
);
}
uploadWorkspaceFile(
req: DaemonWorkspaceFileUploadRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileUploadResult> {
return this.client.uploadFileToPath(
`/workspaces/${this.workspaceSelector}/file/upload`,
'POST /workspaces/:workspace/file/upload',
req,
clientId,
);
}
workspaceSettings(opts?: {
clientId?: string;
}): Promise<DaemonWorkspaceSettingsStatus> {

View file

@ -594,6 +594,8 @@ export type {
DaemonWorkspaceFileBytes,
DaemonWorkspaceFileEditRequest,
DaemonWorkspaceFileEditResult,
DaemonWorkspaceFileUploadRequest,
DaemonWorkspaceFileUploadResult,
DaemonWorkspaceFileWriteRequest,
DaemonWorkspaceFileWriteResult,
DaemonWorkspaceMcpServerStatus,

View file

@ -26,6 +26,8 @@ export interface DaemonCapabilitiesLimits {
maxTotalSessions?: number | null;
/** Server-side deadline for ACP session load/resume. */
sessionRestoreTimeoutMs?: number;
/** Present when `workspace_file_upload` is advertised. */
maxWorkspaceFileUploadBytes?: number;
}
export interface DaemonWorkspaceCapability {
@ -1967,6 +1969,32 @@ export interface DaemonWorkspaceFileEditResult {
matchedIgnore: 'file' | 'directory' | null;
}
/**
* Binary file upload request. The bytes are sent as
* `application/octet-stream`; `path` is the target relative to the workspace
* root. Uploads never overwrite an occupied name is auto-numbered by the
* daemon, and the returned `path` is the final server-confirmed name.
*/
export interface DaemonWorkspaceFileUploadRequest {
path: string;
data: ArrayBuffer | Uint8Array | Blob;
signal?: AbortSignal;
/** Omitted inherits the client's default; `0` disables the timeout. */
timeoutMs?: number;
/**
* Browser-only upload progress. Requesting progress where
* `XMLHttpRequest` is unavailable throws before sending.
*/
onProgress?: (event: { loaded: number; total: number }) => void;
}
export interface DaemonWorkspaceFileUploadResult {
kind: 'file_upload';
path: string;
sizeBytes: number;
hash: DaemonContentHash;
}
/**
* Subagent CRUD types. `agentType` on the wire is
* the `name` field from the agent's frontmatter (case-insensitive);

View file

@ -253,6 +253,8 @@ export {
type DaemonWorkspaceFileBytes,
type DaemonWorkspaceFileEditRequest,
type DaemonWorkspaceFileEditResult,
type DaemonWorkspaceFileUploadRequest,
type DaemonWorkspaceFileUploadResult,
type DaemonWorkspaceFileWriteRequest,
type DaemonWorkspaceFileWriteResult,
type DaemonWorkspaceMemoryDreamOptions,

View file

@ -0,0 +1,722 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import {
DaemonClient,
DaemonHttpError,
} from '../../src/daemon/DaemonClient.js';
import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js';
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
interface CapturedRequest {
url: string;
method: string;
headers: Record<string, string>;
body: unknown;
signal?: AbortSignal | null;
}
function recordingFetch(
reply: (req: CapturedRequest) => Response | Promise<Response>,
): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } {
const calls: CapturedRequest[] = [];
const fetchImpl = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const headers: Record<string, string> = {};
if (init?.headers) {
const h = new Headers(init.headers);
h.forEach((v, k) => (headers[k.toLowerCase()] = v));
}
const captured: CapturedRequest = {
url,
method: init?.method ?? 'GET',
headers,
body: init?.body ?? null,
signal: init?.signal ?? null,
};
calls.push(captured);
return reply(captured);
},
) as unknown as typeof globalThis.fetch;
return { fetch: fetchImpl, calls };
}
describe('uploadWorkspaceFile', () => {
const uploadResult = {
kind: 'file_upload',
path: 'blob.bin',
sizeBytes: 4,
hash: `sha256:${'d'.repeat(64)}`,
};
it('POSTs octet-stream bytes with the path in the query string', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.uploadWorkspaceFile(
{ path: 'blob.bin', data: new Uint8Array([1, 2, 3, 4]) },
'client-1',
),
).resolves.toEqual(uploadResult);
expect(calls[0]?.method).toBe('POST');
expect(calls[0]?.url).toBe('http://daemon/file/upload?path=blob.bin');
expect(calls[0]?.headers['content-type']).toBe('application/octet-stream');
expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1');
});
it('uses direct REST when an ACP transport is configured', async () => {
const { fetch: restFetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const transportFetch = vi.fn(async () =>
jsonResponse(404, { error: 'ACP route not found' }),
);
const transport: DaemonTransport = {
type: 'acp-http',
supportsReplay: true,
connected: true,
restFetch,
fetch: transportFetch,
async *subscribeEvents() {},
dispose() {},
};
const client = new DaemonClient({ baseUrl: 'http://daemon', transport });
await expect(
client.uploadWorkspaceFile({
path: 'blob.bin',
data: new Uint8Array([1]),
}),
).resolves.toEqual(uploadResult);
expect(calls[0]?.url).toBe('http://daemon/file/upload?path=blob.bin');
expect(transportFetch).not.toHaveBeenCalled();
});
it('URL-encodes the path query parameter exactly once', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client.uploadWorkspaceFile({
path: 'a&b+c=d #1 数据 %b.txt',
data: new Uint8Array([0]),
});
const url = new URL(calls[0]!.url);
expect(url.pathname).toBe('/file/upload');
expect(url.searchParams.get('path')).toBe('a&b+c=d #1 数据 %b.txt');
});
it('sends the raw bytes as the request body', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const data = new Uint8Array([7, 8, 9]);
await client.uploadWorkspaceFile({ path: 'a.bin', data });
expect(calls[0]?.body).toBe(data);
});
it('sends Blob bodies untouched on the fetch path', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const data = new Blob(['blob-bytes']);
await client.uploadWorkspaceFile({ path: 'a.bin', data });
expect(calls[0]?.body).toBe(data);
});
it('rejects a 2xx fetch response whose JSON body is missing path', async () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
}),
).rejects.toThrow(/invalid upload response body/);
});
it('rejects a non-JSON 2xx fetch body with the same labeled error as XHR', async () => {
// Proxy/captive-portal interstitials answer 200 + HTML; both transports
// must reject with the labeled error (not a bare SyntaxError on fetch).
const { fetch } = recordingFetch(
() => new Response('<html>interstitial</html>', { status: 200 }),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
}),
).rejects.toThrow(/invalid upload response body/);
});
it('uses the workspace-qualified route via workspaceByCwd', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client
.workspaceByCwd('/repo')
.uploadWorkspaceFile({ path: 'a.bin', data: new Uint8Array([9]) });
expect(calls[0]?.url).toBe(
'http://daemon/workspaces/%2Frepo/file/upload?path=a.bin',
);
});
it('preserves the upload 413 error body', async () => {
const body = {
errorKind: 'file_too_large',
error: 'Request body too large (max 50 MiB)',
status: 413,
maxBytes: 52428800,
};
const { fetch } = recordingFetch(() => jsonResponse(413, body));
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const err = await client
.uploadWorkspaceFile({ path: 'big.bin', data: new Uint8Array(1) })
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(DaemonHttpError);
expect((err as DaemonHttpError).status).toBe(413);
expect((err as DaemonHttpError).body).toEqual(body);
});
it('fails before sending when progress is requested without XMLHttpRequest', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
onProgress: () => {},
}),
).rejects.toThrow(/XMLHttpRequest/);
expect(calls).toHaveLength(0);
});
it('forwards the abort signal to the request', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
// `fetchTimeoutMs: 0` (the upload production config) skips the timeout
// signal composition, so the captured signal is exactly the caller's —
// a dropped `req.signal` would surface as `null` here.
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
fetchTimeoutMs: 0,
});
const ctrl = new AbortController();
await client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
});
expect(calls[0]?.signal).toBe(ctrl.signal);
});
it(
'inherits the client timeout when timeoutMs is omitted',
// The mock fetch settles only via the abort signal; under the exact
// regression this test guards (no timeout armed, no signal composed)
// nothing would abort and the promise would hang into the package
// testTimeout. A per-test budget fails that shape fast.
{ timeout: 5_000 },
async () => {
vi.useFakeTimers();
try {
const fetch = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
}),
) as unknown as typeof globalThis.fetch;
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
fetchTimeoutMs: 25,
});
const result = client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
})
.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(25);
await expect(result).resolves.toMatchObject({ name: 'TimeoutError' });
} finally {
vi.useRealTimers();
}
},
);
it('allows timeoutMs 0 to disable the client timeout', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(201, uploadResult),
);
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
fetchTimeoutMs: 25,
});
await client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
timeoutMs: 0,
});
expect(calls[0]?.signal).toBeNull();
});
it('applies an explicit timeout to progress uploads', async () => {
class FakeXMLHttpRequest {
static latest: FakeXMLHttpRequest | undefined;
timeout = 0;
status = 0;
responseText = '';
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
onabort: (() => void) | null = null;
constructor() {
FakeXMLHttpRequest.latest = this;
}
open = vi.fn();
setRequestHeader = vi.fn();
abort() {
this.onabort?.();
}
send() {
this.ontimeout?.();
}
}
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
try {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const error = await client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
timeoutMs: 17,
onProgress: () => {},
})
.catch((caught: unknown) => caught);
expect(FakeXMLHttpRequest.latest?.timeout).toBe(17);
expect(error).toMatchObject({ name: 'TimeoutError' });
} finally {
vi.unstubAllGlobals();
}
});
it('keeps the XHR timeout disabled for timeoutMs 0 despite the client default', async () => {
class FakeXMLHttpRequest {
static latest: FakeXMLHttpRequest | undefined;
timeout = 0;
status = 201;
responseText = JSON.stringify(uploadResult);
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
onabort: (() => void) | null = null;
constructor() {
FakeXMLHttpRequest.latest = this;
}
open() {}
setRequestHeader() {}
abort() {}
send() {
this.onload?.();
}
}
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
try {
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetchTimeoutMs: 30_000,
});
await client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
timeoutMs: 0,
onProgress: () => {},
});
expect(FakeXMLHttpRequest.latest?.timeout).toBe(0);
} finally {
vi.unstubAllGlobals();
}
});
describe('progress uploads over XMLHttpRequest', () => {
class FakeXMLHttpRequest {
static latest: FakeXMLHttpRequest | undefined;
static sendHook: ((xhr: FakeXMLHttpRequest) => void) | undefined;
timeout = 0;
status = 0;
responseText = '';
sentBody: unknown = undefined;
upload = { onprogress: null as ((event: ProgressEvent) => void) | null };
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
onabort: (() => void) | null = null;
open = vi.fn();
setRequestHeader = vi.fn();
constructor() {
FakeXMLHttpRequest.latest = this;
}
abort() {
this.onabort?.();
}
send(body?: unknown) {
this.sentBody = body;
FakeXMLHttpRequest.sendHook?.(this);
}
}
beforeEach(() => {
FakeXMLHttpRequest.latest = undefined;
FakeXMLHttpRequest.sendHook = undefined;
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('builds the request and maps upload progress events', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const data = new Uint8Array([1, 2, 3]);
const progress: Array<{ loaded: number; total: number }> = [];
const promise = client.uploadWorkspaceFile(
{
path: 'a.bin',
data,
onProgress: (event) => progress.push(event),
},
'client-1',
);
const xhr = FakeXMLHttpRequest.latest!;
expect(xhr.open).toHaveBeenCalledWith(
'POST',
'http://daemon/file/upload?path=a.bin',
);
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
'Content-Type',
'application/octet-stream',
);
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
'X-Qwen-Client-Id',
'client-1',
);
expect(xhr.sentBody).toBe(data);
xhr.upload.onprogress?.({
lengthComputable: true,
loaded: 2,
total: 4,
} as ProgressEvent);
expect(progress).toEqual([{ loaded: 2, total: 4 }]);
xhr.upload.onprogress?.({
lengthComputable: false,
loaded: 9,
total: 0,
} as ProgressEvent);
expect(progress).toHaveLength(1);
xhr.status = 201;
xhr.responseText = JSON.stringify(uploadResult);
xhr.onload?.();
await expect(promise).resolves.toEqual(uploadResult);
});
it('rejects non-2xx responses with a parsed DaemonHttpError', async () => {
const body = {
errorKind: 'file_too_large',
error: 'Request body too large (max 50 MiB)',
status: 413,
maxBytes: 52428800,
};
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const promise = client
.uploadWorkspaceFile({
path: 'big.bin',
data: new Uint8Array([1]),
onProgress: () => {},
})
.catch((caught: unknown) => caught);
const xhr = FakeXMLHttpRequest.latest!;
xhr.status = 413;
xhr.responseText = JSON.stringify(body);
xhr.onload?.();
const error = await promise;
expect(error).toBeInstanceOf(DaemonHttpError);
expect((error as DaemonHttpError).status).toBe(413);
expect((error as DaemonHttpError).body).toEqual(body);
});
it('rejects network failures', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const promise = client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
onProgress: () => {},
})
.catch((caught: unknown) => caught);
FakeXMLHttpRequest.latest!.onerror?.();
const error = await promise;
expect(error).toMatchObject({
message: expect.stringContaining('network request failed'),
});
});
it('aborts on signal cancellation and detaches the abort listener', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const ctrl = new AbortController();
const addSpy = vi.spyOn(ctrl.signal, 'addEventListener');
const removeSpy = vi.spyOn(ctrl.signal, 'removeEventListener');
const promise = client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
onProgress: () => {},
})
.catch((caught: unknown) => caught);
ctrl.abort();
await expect(promise).resolves.toMatchObject({ name: 'AbortError' });
const registered = addSpy.mock.calls.find(
([type]) => type === 'abort',
)?.[1];
expect(registered).toBeTypeOf('function');
expect(removeSpy).toHaveBeenCalledWith('abort', registered);
});
it('rejects and cleans up when xhr.send throws synchronously', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
FakeXMLHttpRequest.sendHook = () => {
throw new Error('detached buffer');
};
const ctrl = new AbortController();
const addSpy = vi.spyOn(ctrl.signal, 'addEventListener');
const removeSpy = vi.spyOn(ctrl.signal, 'removeEventListener');
const error = await client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
onProgress: () => {},
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ message: 'detached buffer' });
const registered = addSpy.mock.calls.find(
([type]) => type === 'abort',
)?.[1];
expect(registered).toBeTypeOf('function');
expect(removeSpy).toHaveBeenCalledWith('abort', registered);
});
it('detaches the abort listener after a successful upload settles', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const ctrl = new AbortController();
const addSpy = vi.spyOn(ctrl.signal, 'addEventListener');
const removeSpy = vi.spyOn(ctrl.signal, 'removeEventListener');
const promise = client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
onProgress: () => {},
});
const xhr = FakeXMLHttpRequest.latest!;
xhr.status = 201;
xhr.responseText = JSON.stringify(uploadResult);
xhr.onload?.();
await expect(promise).resolves.toEqual(uploadResult);
const registered = addSpy.mock.calls.find(
([type]) => type === 'abort',
)?.[1];
expect(registered).toBeTypeOf('function');
expect(removeSpy).toHaveBeenCalledWith('abort', registered);
});
it('rejects a pre-aborted signal before constructing an XHR', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const ctrl = new AbortController();
ctrl.abort();
await expect(
client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
onProgress: () => {},
}),
).rejects.toMatchObject({ name: 'AbortError' });
expect(FakeXMLHttpRequest.latest).toBeUndefined();
});
it('propagates the caller abort reason on a pre-aborted signal', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const sentinel = new Error('sentinel');
const ctrl = new AbortController();
ctrl.abort(sentinel);
await expect(
client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
onProgress: () => {},
}),
).rejects.toBe(sentinel);
});
it('propagates the caller abort reason through xhr abort', async () => {
// Matches the fetch transport (AbortSignal.any carries the reason):
// callers keying on `err === reason` must see the same rejection
// whether or not progress reporting selected the XHR path.
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const sentinel = new Error('sentinel');
const ctrl = new AbortController();
const promise = client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
signal: ctrl.signal,
onProgress: () => {},
})
.catch((caught: unknown) => caught);
expect(FakeXMLHttpRequest.latest).toBeDefined();
ctrl.abort(sentinel);
await expect(promise).resolves.toBe(sentinel);
});
it('rejects a 2xx response with a non-JSON body like the fetch path', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const promise = client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
onProgress: () => {},
})
.catch((caught: unknown) => caught);
const xhr = FakeXMLHttpRequest.latest!;
xhr.status = 200;
xhr.responseText = '<html>interstitial</html>';
xhr.onload?.();
const error = await promise;
expect(error).toMatchObject({
message: expect.stringContaining('invalid upload response body'),
});
});
it('rejects a 2xx response whose JSON body is missing path', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const promise = client
.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
onProgress: () => {},
})
.catch((caught: unknown) => caught);
const xhr = FakeXMLHttpRequest.latest!;
xhr.status = 200;
xhr.responseText = JSON.stringify({});
xhr.onload?.();
const error = await promise;
expect(error).toMatchObject({
message: expect.stringContaining('invalid upload response body'),
});
});
it('sends Blob bodies untouched on the XHR path', async () => {
const client = new DaemonClient({ baseUrl: 'http://daemon' });
const data = new Blob(['blob-bytes']);
const promise = client.uploadWorkspaceFile({
path: 'a.bin',
data,
onProgress: () => {},
});
const xhr = FakeXMLHttpRequest.latest!;
expect(xhr.sentBody).toBe(data);
xhr.status = 201;
xhr.responseText = JSON.stringify(uploadResult);
xhr.onload?.();
await expect(promise).resolves.toEqual(uploadResult);
});
it('inherits the client timeout on the XHR when timeoutMs is omitted', async () => {
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetchTimeoutMs: 30_000,
});
const promise = client.uploadWorkspaceFile({
path: 'a.bin',
data: new Uint8Array([1]),
onProgress: () => {},
});
const xhr = FakeXMLHttpRequest.latest!;
xhr.status = 201;
xhr.responseText = JSON.stringify(uploadResult);
xhr.onload?.();
await expect(promise).resolves.toEqual(uploadResult);
expect(xhr.timeout).toBe(30_000);
});
});
});

View file

@ -539,6 +539,7 @@ vi.mock('./utils/systemInfo', () => ({
vi.mock('./components/ChatEditor', async () => {
const React = await import('react');
const { useWebShellCustomization } = await import('./customization');
return {
ChatEditor: React.memo(
React.forwardRef(function ChatEditor(
@ -563,6 +564,7 @@ vi.mock('./components/ChatEditor', async () => {
testState.chatEditorRenderCount += 1;
testState.latestChatEditorProps = props;
const { onAttachmentsChange } = props;
const customization = useWebShellCustomization();
React.useEffect(() => {
onAttachmentsChange?.(
Boolean(
@ -608,7 +610,13 @@ vi.mock('./components/ChatEditor', async () => {
}));
return React.createElement(
'div',
{ 'data-web-shell-composer': '' },
{
'data-web-shell-composer': '',
'data-file-upload-enabled':
customization.fileUploadEnabled === undefined
? undefined
: String(customization.fileUploadEnabled),
},
React.createElement(
'button',
{
@ -19252,3 +19260,17 @@ describe('App manual-run orchestration (scheduled tasks)', () => {
expect(editorInsertText).not.toHaveBeenCalled(); // but priming skipped
});
});
describe('fileUploadEnabled customization plumbing', () => {
it('reaches the composer customization when the host disables upload', () => {
const { container } = renderApp({ fileUploadEnabled: false });
const composer = container.querySelector('[data-web-shell-composer]');
expect(composer?.getAttribute('data-file-upload-enabled')).toBe('false');
});
it('leaves the customization unset when the prop is omitted', () => {
const { container } = renderApp({});
const composer = container.querySelector('[data-web-shell-composer]');
expect(composer?.hasAttribute('data-file-upload-enabled')).toBe(false);
});
});

View file

@ -931,6 +931,14 @@ export interface WebShellProps {
onSlashCommand?: WebShellSlashCommandHandler;
/** Built-in @ mention providers to enable. Defaults to all built-ins. */
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
/**
* Controls whether the composer's file-upload entry points (drag-and-drop
* and the @ panel upload item) are enabled. Works alongside the daemon's
* `workspace_file_upload` capability, not instead of it: `false` force-
* disables upload even when the daemon advertises the capability, while
* `true`/omitted still requires the capability to be satisfied.
*/
fileUploadEnabled?: boolean;
/** Additional @ mention categories shown alongside built-in files/extensions. */
atProviders?: readonly WebShellAtProvider[];
/** Icon URLs for custom composer tag kinds used by @ mention chips. */
@ -1798,6 +1806,7 @@ export function App({
builtinAtProviders,
atProviders,
composerTagIcons,
fileUploadEnabled,
renderToolHeaderExtra,
renderWelcomeHeader,
renderWelcomeFooter,
@ -2054,6 +2063,7 @@ export function App({
markdownTableMode,
markdown,
loadingPhrases,
fileUploadEnabled,
}),
[
composerTagIcons,
@ -2077,6 +2087,7 @@ export function App({
markdownTableMode,
markdown,
loadingPhrases,
fileUploadEnabled,
],
);
const CustomFooter = renderFooter;

View file

@ -239,6 +239,26 @@ describe('AtMentionPanel', () => {
expect(onSelectTab).toHaveBeenCalledWith('hg');
});
it('renders the upload item with an upload icon', () => {
const menu = itemsMenu();
menu.items = [
{
id: 'upload-file',
label: 'Upload file',
kind: 'upload',
insertText: '',
description: 'Upload a file into this folder',
},
];
mount(menu);
expect(document.body.textContent).toContain('Upload file');
expect(document.body.textContent).toContain(
'Upload a file into this folder',
);
expect(document.body.querySelector('svg.lucide-upload')).not.toBeNull();
});
it('guards image icon sources', () => {
const menu = itemsMenu();
menu.items = [

View file

@ -8,6 +8,7 @@ import {
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { UploadIcon } from 'lucide-react';
import { useI18n } from '../i18n';
import { useWebShellPortalRoot } from '../portalRoot';
import {
@ -175,7 +176,8 @@ export function AtMentionPanel({
labelTitle: item.label,
subtitle: item.subtitle,
description:
menu.selectedProviderId === FILE_PROVIDER_ID
menu.selectedProviderId === FILE_PROVIDER_ID &&
item.kind !== 'upload'
? undefined
: (item.description ?? item.detail),
icon: item.icon,
@ -443,7 +445,13 @@ export function AtMentionPanel({
<>
<span className={styles.atItemMain}>
<span className={styles.atItemLeading}>
{'icon' in row &&
{'item' in row && row.item.kind === 'upload' ? (
<UploadIcon
className={styles.atItemUploadIcon}
aria-hidden="true"
/>
) : (
'icon' in row &&
safeIcon &&
(row.iconMode === 'image' ? (
<img
@ -465,7 +473,8 @@ export function AtMentionPanel({
title={row.iconTooltip}
aria-hidden="true"
/>
))}
))
)}
<span
className={styles.atItemLabel}
title={row.labelTitle}

View file

@ -536,6 +536,13 @@
-webkit-mask: var(--at-item-icon-url) center / contain no-repeat;
}
.atItemUploadIcon {
align-self: center;
width: 14px;
height: 14px;
color: var(--muted-foreground);
}
.atItemImageIcon {
display: block;
width: 16px;
@ -2031,3 +2038,116 @@
color: var(--chat-editor-text-dimmed);
font-size: 12px;
}
/* File upload ----------------------------------------------------------- */
.hiddenUploadInput {
display: none;
}
.uploadStrip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 0 8px;
}
.uploadRow {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 8px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 4px;
background: var(--chat-editor-bg-tertiary);
color: var(--chat-editor-text-primary);
font-size: 12px;
line-height: 1.4;
}
.uploadRow svg {
flex: 0 0 auto;
width: 14px;
height: 14px;
}
.uploadRowSpinner {
animation: uploadRowSpin 1s linear infinite;
}
/* Must follow the base rule: equal-specificity cascade resolves to the
last declaration, so an earlier override would stay dead code. */
@media (prefers-reduced-motion: reduce) {
.uploadRowSpinner {
animation: none;
}
}
@keyframes uploadRowSpin {
to {
transform: rotate(360deg);
}
}
.uploadRowName {
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.uploadRowStatus {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--chat-editor-text-dimmed);
}
.uploadRow[data-status='error'] .uploadRowStatus {
color: var(--error-color);
}
.uploadRowAction {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--chat-editor-text-dimmed);
cursor: pointer;
}
.uploadRowAction:hover {
background: var(--chat-editor-border-color);
color: var(--chat-editor-text-primary);
}
.uploadDropOverlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 2px dashed var(--chat-editor-accent-color);
border-radius: 8px;
background: color-mix(
in srgb,
var(--chat-editor-accent-color) 12%,
transparent
);
color: var(--chat-editor-text-primary);
font-size: 13px;
pointer-events: none;
}

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,17 @@ import {
useRef,
useState,
} from 'react';
import type { ReactNode, RefObject } from 'react';
import type {
ReactNode,
RefObject,
DragEvent as ReactDragEvent,
ChangeEvent as ReactChangeEvent,
} from 'react';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk';
import {
DAEMON_APPROVAL_MODES,
useOptionalWorkspace,
} from '@qwen-code/webui/daemon-react-sdk';
import type { CommandInfo } from '../adapters/types';
import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk';
import type {
@ -43,6 +51,8 @@ import {
getComposerTagValue,
} from '../hooks/useComposerCore';
import { AtMentionPanel } from './AtMentionPanel';
import { useFileUpload, type FileUploadItem } from '../hooks/useFileUpload';
import { fileReferenceInsertText } from '../hooks/useAtMentionMenu';
import { cssUrlVar } from '../utils/cssUrlVar';
import {
getComposerTagIconUrl,
@ -54,6 +64,7 @@ import { planSlashSectionRows } from '../utils/slashSectionPlan';
import { getModelDisplayName } from '../utils/modelDisplay';
import { getContextUsageLevel } from '../utils/contextUsage';
import { formatContextUsageDetail } from '../utils/formatTokenCount';
import { normalizeImageMediaType } from '../utils/imageIngestion';
import { VoiceButton } from '../voice/VoiceButton';
import { LiveVoiceButton } from '../live/LiveVoiceButton';
import type {
@ -72,6 +83,9 @@ import {
ChevronDownIcon,
ChevronRightIcon,
FolderClosedIcon,
LoaderCircleIcon,
UploadIcon,
XIcon,
} from 'lucide-react';
import { WorkspaceSelector } from './WorkspaceSelector';
import {
@ -108,6 +122,23 @@ export type ComposerToolbarAction =
| 'voice'
| 'workspace';
// Dropped folders surface in `dataTransfer.files` as 0-byte Files; only the
// items API can tell them apart, and folder uploads are out of scope.
function collectDroppedFiles(dataTransfer: DataTransfer): File[] {
const items = dataTransfer.items;
if (!items || items.length === 0) {
return Array.from(dataTransfer.files);
}
const files: File[] = [];
for (const item of Array.from(items)) {
if (item.kind !== 'file') continue;
if (item.webkitGetAsEntry?.()?.isDirectory) continue;
const file = item.getAsFile();
if (file) files.push(file);
}
return files;
}
const ACTIVE_TOOLBAR_ACTIONS = [
'approvalMode',
'contextUsage',
@ -1442,8 +1473,91 @@ export const ChatEditor = memo(
renderComposerTagTooltip,
onComposerTagClick,
parseUserMessageContent,
fileUploadEnabled,
} = useWebShellCustomization();
// File-upload picker. The @ panel's "Upload file" item reports the browsed
// directory; we click a hidden <input type="file"> so the browser treats it
// as part of the user gesture, then upload into that directory.
const fileInputRef = useRef<HTMLInputElement | null>(null);
const uploadPickerTargetRef = useRef('.');
const uploadPickerTargetKeyRef = useRef('');
const uploadPickerRestoreRef = useRef<(() => void) | undefined>(undefined);
// Resolve the upload target BEFORE useComposerCore so the @ panel's upload
// item can be capability-gated (hidden on daemons without the feature).
const uploadWorkspace = useOptionalWorkspace();
const uploadTarget = useMemo(() => {
if (!uploadWorkspace) return undefined;
// The host prop can force-disable upload even when the daemon advertises
// the capability; it does NOT bypass the capability check. Both must
// allow: `fileUploadEnabled === false` short-circuits, otherwise the
// `workspace_file_upload` capability is still required.
if (fileUploadEnabled === false) return undefined;
const features = uploadWorkspace.capabilities?.features ?? [];
if (!features.includes('workspace_file_upload')) return undefined;
if (atWorkspaceCwd) {
if (!features.includes('workspace_qualified_rest_core'))
return undefined;
// The selected workspace must be present exactly once and trusted.
const matches = (uploadWorkspace.capabilities?.workspaces ?? []).filter(
(w) => w.cwd === atWorkspaceCwd,
);
if (matches.length !== 1 || matches[0].trusted === false)
return undefined;
return {
client: uploadWorkspace.client.workspaceByCwd(atWorkspaceCwd),
targetKey: atWorkspaceCwd,
};
}
const primaryMatches = (
uploadWorkspace.capabilities?.workspaces ?? []
).filter((workspace) => workspace.primary);
if (primaryMatches.length !== 1 || primaryMatches[0].trusted !== true)
return undefined;
return { client: uploadWorkspace.client, targetKey: '<primary>' };
}, [uploadWorkspace, atWorkspaceCwd, fileUploadEnabled]);
const uploadEnabled = uploadTarget !== undefined;
const maxUploadBytes =
uploadWorkspace?.capabilities?.limits?.maxWorkspaceFileUploadBytes ??
50 * 1024 * 1024;
const uploadTargetKey = `${sessionId ?? '<no-session>'}:${
uploadTarget?.targetKey ?? '<none>'
}`;
// -- File upload ----------------------------------------------------------
// The hook's cancel/reset granularity includes the session: ChatEditor is
// shared across sessions (a switch swaps the doc in the same EditorView),
// so an upload that survives a same-workspace session switch would append
// its @file reference to the other session's draft.
const fileUpload = useFileUpload({
client: uploadTarget?.client,
maxBytes: maxUploadBytes,
targetKey: uploadTargetKey,
});
const triggerFilePicker = useCallback(
(targetDir: string, restoreQuery?: () => void) => {
uploadPickerTargetRef.current = targetDir;
uploadPickerTargetKeyRef.current = uploadTargetKey;
uploadPickerRestoreRef.current = restoreQuery;
fileInputRef.current?.click();
},
[uploadTargetKey],
);
// A pending picker restore belongs to the CURRENT target's draft. Flush
// it before a target change (session switch, capability flip) persists or
// replaces the doc: afterwards the editor holds another draft, and the
// layout effect runs before the composer's passive session-swap effect
// saves the outgoing draft. Also covers the picker being torn down while
// the OS dialog is open — no cancel/change event will ever arrive.
useLayoutEffect(() => {
const restore = uploadPickerRestoreRef.current;
uploadPickerRestoreRef.current = undefined;
restore?.();
}, [uploadTargetKey]);
const core = useComposerCore({
onSubmit,
onInputTextChange,
@ -1475,6 +1589,8 @@ export const ChatEditor = memo(
renderComposerTagTooltip,
onComposerTagClick,
onImageIngestionNotice,
onFileUploadRequest: uploadEnabled ? triggerFilePicker : undefined,
workspaceUploadBusy: fileUpload.isBusy,
editorTheme: CHAT_EDITOR_THEME,
});
@ -1482,6 +1598,201 @@ export const ChatEditor = memo(
useImperativeHandle(ref, () => core.handle, [core.handle]);
const addComposerTags = core.addTags;
const clearImageDragState = core.clearImageDragState;
const insertUploadReference = useCallback(
(path: string) => {
const serialized = fileReferenceInsertText(path).trim();
addComposerTags(
[
{
id: `file:${serialized}`,
kind: 'file',
value: path,
serialized,
},
],
{ placement: 'inline', position: 'end' },
);
},
[addComposerTags],
);
const uploadStatusText = (upload: FileUploadItem): string => {
switch (upload.status) {
case 'pending':
return t('composer.upload.pending');
case 'uploading':
return `${t('composer.upload.uploading')} ${Math.round(
upload.progress * 100,
)}%`;
case 'done':
return upload.resultPath !== undefined &&
upload.resultPath !== upload.targetPath
? `${t('composer.upload.renamed')} ${upload.resultPath}`
: t('composer.upload.done');
case 'error':
return (
upload.error ??
(upload.errorCode === 'tooLarge'
? t('composer.upload.error.tooLarge', {
limit: `${Math.round(maxUploadBytes / (1024 * 1024))} MiB`,
})
: upload.errorCode === 'noDaemon'
? t('composer.upload.error.noDaemon')
: upload.errorCode === 'tooManyFiles'
? t('composer.upload.error.tooManyFiles', {
count: upload.skippedCount ?? 0,
})
: t('composer.upload.error'))
);
}
};
const [uploadDragActive, setUploadDragActive] = useState(false);
const uploadDragDepthRef = useRef(0);
const handleUploadDragEnter = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (
!uploadEnabled ||
disabled ||
!event.dataTransfer.types.includes('Files')
)
return;
event.preventDefault();
uploadDragDepthRef.current += 1;
setUploadDragActive(true);
},
[uploadEnabled, disabled],
);
const handleUploadDragOver = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (
!uploadEnabled ||
disabled ||
!event.dataTransfer.types.includes('Files')
)
return;
event.preventDefault();
},
[uploadEnabled, disabled],
);
const handleUploadDragLeave = useCallback(() => {
if (uploadDragDepthRef.current === 0) return;
uploadDragDepthRef.current = Math.max(0, uploadDragDepthRef.current - 1);
if (uploadDragDepthRef.current === 0) setUploadDragActive(false);
}, []);
const clearUploadDragState = useCallback(() => {
uploadDragDepthRef.current = 0;
setUploadDragActive(false);
}, []);
// Shell regions outside the drop-handling surface (e.g. the upload
// strip) must still cancel file drags; an uncancelled drop navigates
// the tab to the file, tearing down the SPA mid-turn.
const cancelShellFileDrag = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (event.dataTransfer.types.includes('Files')) event.preventDefault();
},
[],
);
// `uploadFiles` is a stable callback from the hook; depend on it (not the
// freshly-created `fileUpload` object) so these handlers are not rebuilt
// on every render.
const uploadFiles = fileUpload.uploadFiles;
const handleUploadDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes('Files')) {
core.imageTransferHandlers.onDropCapture(event);
return;
}
const files = collectDroppedFiles(event.dataTransfer);
uploadDragDepthRef.current = 0;
setUploadDragActive(false);
if (disabled) {
// Cancel the drop itself; otherwise the browser navigates the tab
// to the dropped file, tearing down the Web Shell SPA mid-turn.
event.preventDefault();
return;
}
if (
!uploadEnabled ||
files.length === 0 ||
files.every((file) => normalizeImageMediaType(file.type, file.name))
) {
core.imageTransferHandlers.onDropCapture(event);
return;
}
clearImageDragState();
event.preventDefault();
event.stopPropagation();
uploadFiles(files, '.', insertUploadReference);
},
[
core.imageTransferHandlers,
clearImageDragState,
disabled,
uploadEnabled,
uploadFiles,
insertUploadReference,
],
);
const handleUploadPickerChange = useCallback(
(event: ReactChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
const targetDir = uploadPickerTargetRef.current;
const capturedKey = uploadPickerTargetKeyRef.current;
const restore = uploadPickerRestoreRef.current;
uploadPickerRestoreRef.current = undefined;
event.target.value = '';
// Only upload if the target workspace is unchanged since the picker
// opened; otherwise a stale directory path would land in the newly
// selected workspace (the hook's generation cancel only clears items
// already queued, not this fresh call).
if (
files.length > 0 &&
capturedKey !== '' &&
capturedKey === uploadTargetKey
) {
const queued = uploadFiles(files, targetDir, insertUploadReference);
if (queued === 0) {
// Every chosen file was rejected locally (e.g. all oversized):
// the picker closed without any upload, so give the query back.
restore?.();
}
} else {
// The @ panel deleted the mention query before opening the picker.
// A blocked or empty selection must give it back, like the native
// cancel path does, instead of silently eating the typed text.
restore?.();
}
},
[uploadFiles, insertUploadReference, uploadTargetKey],
);
useEffect(() => {
// React only wires `cancel` on <dialog>; the file input needs a native
// listener. `cancel` does not bubble, so delegation cannot see it.
const input = fileInputRef.current;
if (!uploadEnabled || !input) return;
const onCancel = () => {
const restore = uploadPickerRestoreRef.current;
uploadPickerRestoreRef.current = undefined;
restore?.();
};
input.addEventListener('cancel', onCancel);
return () => input.removeEventListener('cancel', onCancel);
}, [uploadEnabled]);
useEffect(() => {
if (!uploadDragActive) return;
window.addEventListener('dragend', clearUploadDragState);
window.addEventListener('blur', clearUploadDragState);
return () => {
window.removeEventListener('dragend', clearUploadDragState);
window.removeEventListener('blur', clearUploadDragState);
};
}, [clearUploadDragState, uploadDragActive]);
useEffect(() => {
if (disabled) clearUploadDragState();
}, [clearUploadDragState, disabled]);
useEffect(() => {
onAttachmentsChange?.(core.hasAttachments);
}, [core.hasAttachments, onAttachmentsChange]);
@ -2118,15 +2429,80 @@ export const ChatEditor = memo(
}`}
data-composer
data-web-shell-composer
onDragOver={cancelShellFileDrag}
onDrop={cancelShellFileDrag}
>
{fileUpload.uploads.length > 0 && (
<div className={styles.uploadStrip} data-web-shell-upload-strip>
{/* One live region per strip, fed only by terminal transitions:
per-row regions would announce every >=2% progress tick. */}
<div className={styles.srOnly} role="status" aria-live="polite">
{fileUpload.uploads
.filter(
(upload) =>
upload.status === 'done' || upload.status === 'error',
)
.map(
(upload) =>
`${upload.file.name}: ${uploadStatusText(upload)}`,
)
.join(' \u2014 ')}
</div>
{fileUpload.uploads.map((upload) => {
const busy =
upload.status === 'pending' || upload.status === 'uploading';
return (
<div
key={upload.id}
className={styles.uploadRow}
data-status={upload.status}
>
{busy ? (
<LoaderCircleIcon
className={styles.uploadRowSpinner}
aria-hidden="true"
/>
) : (
<UploadIcon aria-hidden="true" />
)}
<span className={styles.uploadRowName}>
{upload.file.name}
</span>
<span className={styles.uploadRowStatus}>
{uploadStatusText(upload)}
</span>
<button
type="button"
className={styles.uploadRowAction}
aria-label={
busy
? t('composer.upload.cancel')
: t('composer.upload.dismiss')
}
onClick={() => fileUpload.removeUpload(upload.id)}
>
<XIcon aria-hidden="true" />
</button>
</div>
);
})}
</div>
)}
<div
ref={containerRef}
className={styles.container}
data-web-shell-composer-surface
data-upload-drag-active={uploadDragActive || undefined}
data-typewriter-visible={showTypewriterPlaceholder || undefined}
data-image-drag-active={core.imageDragActive || undefined}
data-image-drag-active={
(core.imageDragActive && !uploadDragActive) || undefined
}
aria-busy={core.pendingImageBatchCount > 0 || undefined}
{...core.imageTransferHandlers}
onDragEnter={handleUploadDragEnter}
onDragOver={handleUploadDragOver}
onDragLeave={handleUploadDragLeave}
onDropCapture={handleUploadDrop}
onClick={() => {
setModeDropdownOpen(false);
setModelDropdownOpen(false);
@ -2135,6 +2511,18 @@ export const ChatEditor = memo(
}}
>
<SpecularComposerEffect targetRef={containerRef} />
{uploadEnabled && (
<input
ref={fileInputRef}
type="file"
multiple
className={styles.hiddenUploadInput}
data-web-shell-upload-input
onChange={handleUploadPickerChange}
tabIndex={-1}
aria-hidden="true"
/>
)}
{searchMode && (
<div
ref={searchUiRef}
@ -2298,6 +2686,15 @@ export const ChatEditor = memo(
)}
</div>
)}
{uploadDragActive && (
<div
className={styles.uploadDropOverlay}
data-web-shell-upload-drop-overlay
>
<UploadIcon aria-hidden="true" />
<span>{t('composer.upload.drop')}</span>
</div>
)}
{core.slashMenu && (
<SlashCommandPanel
menu={core.slashMenu}

View file

@ -263,6 +263,12 @@ export type WebShellComposerTagPlacement = 'top' | 'inline';
export interface WebShellComposerTagOptions {
placement?: WebShellComposerTagPlacement;
/**
* Inline placement only: insert at the caret (default, synchronous user
* gestures) or append after the document end (asynchronous producers,
* which must not interrupt typing or steal focus).
*/
position?: 'caret' | 'end';
}
export interface WebShellComposerTextOptions {
@ -496,6 +502,15 @@ export interface WebShellCustomization {
markdownTableMode?: MarkdownTableMode;
markdown?: WebShellMarkdownCustomization;
loadingPhrases?: LoadingPhrasesResolver;
/**
* Controls whether the composer's file-upload entry points (drag-and-drop
* and the @ panel upload item) are enabled. Works alongside the daemon's
* `workspace_file_upload` capability, not instead of it: setting `false`
* force-disables upload even when the daemon advertises the capability,
* while `true`/omitted still requires the capability (and the workspace
* trust / qualified-route safety checks) to be satisfied.
*/
fileUploadEnabled?: boolean;
}
const WebShellCustomizationContext = createContext<WebShellCustomization>({});

View file

@ -21,6 +21,9 @@ type HookResult = ReturnType<typeof useAtMentionMenu>;
let latest: HookResult | null = null;
let container: HTMLDivElement | null = null;
let root: Root | null = null;
// Stable identity across renders (like useComposerCore's ref) so tests can
// flip disabled mid-interaction without remounting.
const harnessDisabledRef = { current: false };
function makeView(doc: string): EditorView {
return {
@ -53,6 +56,7 @@ function Harness({
shellMode = false,
view,
createInlineTagEffect,
onUploadRequest,
}: {
actions?: AtMentionWorkspaceActions;
disabled?: boolean;
@ -65,15 +69,18 @@ function Harness({
to: number;
tag: WebShellComposerTag;
}) => StateEffect<unknown>;
onUploadRequest?: (targetDir: string, restoreQuery?: () => void) => void;
}) {
harnessDisabledRef.current = disabled;
latest = useAtMentionMenu({
viewRef: { current: view ?? null },
disabledRef: { current: disabled },
disabledRef: harnessDisabledRef,
shellModeRef: { current: shellMode },
workspaceActionsRef: { current: actions },
builtinProviders,
providers,
createInlineTagEffect,
onUploadRequest,
});
return null;
}
@ -86,6 +93,7 @@ function mount({
shellMode,
view,
createInlineTagEffect,
onUploadRequest,
}: {
actions?: AtMentionWorkspaceActions;
disabled?: boolean;
@ -98,6 +106,7 @@ function mount({
to: number;
tag: WebShellComposerTag;
}) => StateEffect<unknown>;
onUploadRequest?: (targetDir: string, restoreQuery?: () => void) => void;
} = {}) {
container = document.createElement('div');
document.body.appendChild(container);
@ -112,6 +121,7 @@ function mount({
shellMode={shellMode}
view={view}
createInlineTagEffect={createInlineTagEffect}
onUploadRequest={onUploadRequest}
/>,
);
});
@ -172,6 +182,290 @@ describe('useAtMentionMenu', () => {
]);
});
it('removes the triggering mention before opening the upload picker', async () => {
vi.useFakeTimers();
const view = makeView('@');
const onUploadRequest = vi.fn();
mount({
view,
builtinProviders: ['files'],
onUploadRequest,
actions: {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list',
path: '.',
entries: [],
truncated: false,
}),
},
});
const order: string[] = [];
view.dispatch = vi.fn(() => {
order.push('dispatch');
});
onUploadRequest.mockImplementation(() => {
order.push('upload');
});
act(() => latest!.refreshForView(view));
act(() => latest!.enterCategory(0));
await runDebounce();
act(() => expect(latest!.accept(0)).toBe(true));
expect(view.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 1, insert: '' },
selection: { anchor: 0 },
});
expect(onUploadRequest).toHaveBeenCalledWith('.', expect.any(Function));
// The mention must be removed before the picker opens: the native file
// dialog blocks the event loop, so anything deferred until after it
// would stay visible (or be skipped) for the whole picker session.
expect(order).toEqual(['dispatch', 'upload']);
});
it('restores the removed mention through the restore callback', async () => {
vi.useFakeTimers();
const view = makeView('hello @src/');
const dispatched: unknown[] = [];
view.dispatch = vi.fn((tr: unknown) => {
dispatched.push(tr);
});
let restoreQuery: (() => void) | undefined;
const onUploadRequest = vi.fn(
(_targetDir: string, restore?: () => void) => {
restoreQuery = restore;
},
);
mount({
view,
builtinProviders: ['files'],
onUploadRequest,
actions: {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list',
path: 'src',
entries: [],
truncated: false,
}),
},
});
act(() => latest!.refreshForView(view));
await runDebounce();
expect(latest!.state?.items[0]?.id).toBe('upload-file');
act(() => expect(latest!.accept(0)).toBe(true));
expect(dispatched).toHaveLength(1);
// A picker canceled without an upload must give the typed query back.
act(() => restoreQuery!());
expect(dispatched).toHaveLength(2);
expect(dispatched[1]).toEqual({
changes: { from: 6, insert: '@src/' },
selection: { anchor: 11 },
});
});
it('does not restore a removed mention into a different document', async () => {
vi.useFakeTimers();
const view = makeView('@src/');
let restoreQuery: (() => void) | undefined;
const onUploadRequest = vi.fn(
(_targetDir: string, restore?: () => void) => {
restoreQuery = restore;
},
);
mount({
view,
builtinProviders: ['files'],
onUploadRequest,
actions: {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list',
path: 'src',
entries: [],
truncated: false,
}),
},
});
act(() => latest!.refreshForView(view));
await runDebounce();
act(() => expect(latest!.accept(0)).toBe(true));
setViewState(view, 'new session draft');
act(() => restoreQuery!());
expect(view.dispatch).toHaveBeenCalledOnce();
});
it('restores the mention when an end-placed upload tag landed mid-picker', async () => {
vi.useFakeTimers();
const view = makeView('hello @src/');
const dispatched: unknown[] = [];
view.dispatch = vi.fn((tr: unknown) => {
dispatched.push(tr);
});
let restoreQuery: (() => void) | undefined;
const onUploadRequest = vi.fn(
(_targetDir: string, restore?: () => void) => {
restoreQuery = restore;
},
);
mount({
view,
builtinProviders: ['files'],
onUploadRequest,
actions: {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list',
path: 'src',
entries: [],
truncated: false,
}),
},
});
act(() => latest!.refreshForView(view));
await runDebounce();
act(() => expect(latest!.accept(0)).toBe(true));
// While the OS picker is open, an earlier upload completes and appends
// its @file tag at the doc end. Canceling must still give the typed
// query back: the append does not move the insertion point.
setViewState(view, 'hello @src/@report.pdf ');
act(() => restoreQuery!());
expect(dispatched).toHaveLength(2);
expect(dispatched[1]).toEqual({
changes: { from: 6, insert: '@src/' },
selection: { anchor: 11 },
});
});
it('uploads into the directory shown by a typed path query', async () => {
vi.useFakeTimers();
const view = makeView('@src/');
const onUploadRequest = vi.fn();
mount({
view,
builtinProviders: ['files'],
onUploadRequest,
actions: {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list',
path: 'src',
entries: [],
truncated: false,
}),
},
});
act(() => latest!.refreshForView(view));
await runDebounce();
// The menu browses `src/` from the typed query even though
// fileDirectoryRef still holds the click-navigation value ('.'), so the
// upload item must target the displayed directory.
expect(latest!.state?.items[0]?.id).toBe('upload-file');
act(() => expect(latest!.accept(0)).toBe(true));
expect(onUploadRequest).toHaveBeenCalledWith('src', expect.any(Function));
});
it('hides the upload item while filtering files with an entry query', async () => {
vi.useFakeTimers();
const listDirectory = vi.fn().mockResolvedValue({
kind: 'list',
path: '.',
entries: [
{ name: 'file-0.ts', kind: 'file', ignored: false },
{ name: 'other.txt', kind: 'file', ignored: false },
],
truncated: false,
});
mount({
actions: { listDirectory },
onUploadRequest: vi.fn(),
});
act(() => latest!.refreshForView(makeView('@file-0')));
await runDebounce();
expect(latest!.state?.items.some((item) => item.id === 'upload-file')).toBe(
false,
);
expect(latest!.state?.items.map((item) => item.label)).toContain(
'file-0.ts',
);
});
it('closes the upload item without dispatching once the composer is disabled', async () => {
vi.useFakeTimers();
const view = makeView('@');
const onUploadRequest = vi.fn();
mount({
view,
builtinProviders: ['files'],
onUploadRequest,
actions: {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list',
path: '.',
entries: [],
truncated: false,
}),
},
});
act(() => latest!.refreshForView(view));
act(() => latest!.enterCategory(0));
await runDebounce();
harnessDisabledRef.current = true;
act(() => expect(latest!.accept(0)).toBe(true));
expect(view.dispatch).not.toHaveBeenCalled();
expect(onUploadRequest).not.toHaveBeenCalled();
expect(latest!.state).toBeNull();
});
it('keeps the typed query when a stale upload item outlives the handler', async () => {
vi.useFakeTimers();
const view = makeView('@src/');
const onUploadRequest = vi.fn();
const actions = {
listDirectory: vi.fn().mockResolvedValue({
kind: 'list' as const,
path: 'src',
entries: [],
truncated: false,
}),
};
mount({ view, builtinProviders: ['files'], onUploadRequest, actions });
act(() => latest!.refreshForView(view));
await runDebounce();
expect(latest!.state?.items[0]?.id).toBe('upload-file');
// Upload availability can vanish while the menu stays open (items are
// only recomputed on a workspace-key change): accepting the stale item
// must close the menu without eating the typed query.
act(() => {
root!.render(
<Harness
view={view}
builtinProviders={['files']}
onUploadRequest={undefined}
actions={actions}
/>,
);
});
act(() => expect(latest!.accept(0)).toBe(true));
expect(view.dispatch).not.toHaveBeenCalled();
expect(onUploadRequest).not.toHaveBeenCalled();
expect(latest!.state).toBeNull();
});
it('reuses an unchanged category menu and limits rendered providers', () => {
const providers = Array.from({ length: 100 }, (_, index) => ({
id: `custom-${index}`,
@ -926,6 +1220,62 @@ describe('useAtMentionMenu', () => {
expect(latest!.state?.items[50]?.label).toBe('file-49.ts');
});
it('keeps fifty file entries when the upload item is prepended', async () => {
vi.useFakeTimers();
const listDirectory = vi.fn().mockResolvedValue({
kind: 'list',
path: '.',
entries: Array.from({ length: 55 }, (_, index) => ({
name: `file-${String(index).padStart(2, '0')}.ts`,
kind: 'file',
ignored: false,
})),
truncated: false,
});
mount({
actions: { listDirectory },
onUploadRequest: vi.fn(),
});
act(() => latest!.refreshForView(makeView('@')));
act(() => latest!.enterCategory(0));
await runDebounce();
expect(latest!.state?.items).toHaveLength(52);
expect(latest!.state?.items[0]?.id).toBe('upload-file');
expect(latest!.state?.items[1]?.id).toBe('current:.');
expect(latest!.state?.items[51]?.label).toBe('file-49.ts');
});
it('keeps prefix items for directory queries like @src/', async () => {
vi.useFakeTimers();
const listDirectory = vi.fn().mockResolvedValue({
kind: 'list',
path: 'src',
entries: Array.from({ length: 55 }, (_, index) => ({
name: `file-${String(index).padStart(2, '0')}.ts`,
kind: 'file',
ignored: false,
})),
truncated: false,
});
mount({
actions: { listDirectory },
onUploadRequest: vi.fn(),
});
// The entry query is empty here too, so the provider prepends the same
// two prefix items and the menu cap must grant the extra slots.
act(() => latest!.refreshForView(makeView('@src/')));
await runDebounce();
expect(latest!.state?.query).toBe('src/');
expect(latest!.state?.items).toHaveLength(52);
expect(latest!.state?.items[0]?.id).toBe('upload-file');
expect(latest!.state?.items[1]?.id).toBe('current:src');
expect(latest!.state?.items[51]?.label).toBe('file-49.ts');
});
it('keeps built-in providers when custom provider ids collide', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const search = vi.fn().mockResolvedValue([]);

View file

@ -25,7 +25,7 @@ export interface AtMentionProviderView {
}
export interface AtMentionItem extends WebShellAtItem {
kind?: 'insert' | 'directory' | 'mcp-server';
kind?: 'insert' | 'directory' | 'mcp-server' | 'upload';
targetPath?: string;
serverName?: string;
}
@ -172,13 +172,23 @@ export interface UseAtMentionMenuOptions {
to: number;
tag: WebShellComposerTag;
}) => StateEffect<unknown>;
/**
* Invoked when the user selects the synthetic "Upload file" item in the
* file provider. Receives the directory currently being browsed and a
* callback that re-inserts the mention query removed before the picker
* opened call it when the picker closes without an upload so the typed
* text is not lost. When absent (upload unsupported), the item is hidden.
*/
onUploadRequest?: (targetDir: string, restoreQuery?: () => void) => void;
}
const AT_PATTERN = /@((?:[\p{L}\p{N}_./:-]|\\.)*)$/u;
const EMPTY_PROVIDERS: readonly WebShellAtProvider[] = [];
const SEARCH_DEBOUNCE_MS = 150;
const ITEM_LIMIT = 50;
const FILE_ROOT_ITEM_LIMIT = ITEM_LIMIT + 1;
// The empty-query file root view prefixes the current-directory item and,
// when upload is available, the upload item ahead of the file entries.
const FILE_ROOT_ITEM_LIMIT = ITEM_LIMIT + 2;
export const FILE_PROVIDER_ID = 'files';
const EXTENSIONS_PROVIDER_ID = 'extensions';
export const MCP_RESOURCES_PROVIDER_ID = 'mcp-resources';
@ -528,6 +538,15 @@ function sanitizeInsertText(raw: string): string {
return stripped.length > 0 ? stripped : SAFE_DISPLAY_FALLBACK;
}
/**
* Build the composer insert text for a workspace file reference, e.g.
* `@path/to/file `. Shared by the @ file provider and the file-upload flow so
* both escape identically (filenames with spaces / non-ASCII / `%` are common).
*/
export function fileReferenceInsertText(filePath: string): string {
return `@${escapeAtReferenceText(sanitizeInsertText(filePath))} `;
}
function safeDisplayText(raw: string | undefined): string {
if (raw === undefined) return SAFE_DISPLAY_FALLBACK;
return sanitizeDisplayText(raw) ?? SAFE_DISPLAY_FALLBACK;
@ -644,6 +663,7 @@ function createFileProvider(
getCache: () => BuiltinProviderCache,
label: string,
description: string,
getUploadItem: () => AtMentionItem | null,
): WebShellAtProvider {
return {
id: FILE_PROVIDER_ID,
@ -677,13 +697,16 @@ function createFileProvider(
insertText: directoryInsertText(dirPath),
kind: 'insert',
};
// The upload item only shows with an empty entry query (like the
// current-directory item) so it never pollutes filtered results.
const uploadItem = entryQuery ? null : getUploadItem();
return [
...(uploadItem ? [uploadItem] : []),
...(entryQuery ? [] : [currentDirectoryItem]),
...entries.map((entry): AtMentionItem => {
...entries.slice(0, ITEM_LIMIT).map((entry): AtMentionItem => {
const path = joinWorkspacePath(dirPath, entry.name);
const safeName = safeDisplayText(entry.name);
const safePath = sanitizeDisplayText(path);
const safeInsertPath = sanitizeInsertText(path);
if (entry.kind === 'directory') {
return {
id: `dir:${path}`,
@ -697,7 +720,7 @@ function createFileProvider(
id: `file:${path}`,
label: safeName,
description: safePath,
insertText: `@${escapeAtReferenceText(safeInsertPath)} `,
insertText: fileReferenceInsertText(path),
kind: 'insert',
};
}),
@ -727,7 +750,7 @@ function createFileProvider(
.map((file) => ({
id: file,
label: safeDisplayText(file),
insertText: `@${escapeAtReferenceText(sanitizeInsertText(file))} `,
insertText: fileReferenceInsertText(file),
kind: 'insert',
}));
} catch (error) {
@ -903,6 +926,7 @@ export function useAtMentionMenu({
builtinProviders,
providers = EMPTY_PROVIDERS,
createInlineTagEffect,
onUploadRequest,
}: UseAtMentionMenuOptions) {
const { t } = useI18n();
const [state, setState] = useState<AtMentionMenuState | null>(null);
@ -926,6 +950,16 @@ export function useAtMentionMenu({
() => builtinCacheRef.current,
t('at.category.files'),
t('at.category.files.description'),
() =>
onUploadRequest
? {
id: 'upload-file',
label: t('at.files.upload'),
description: t('at.files.upload.description'),
kind: 'upload',
insertText: '',
}
: null,
),
createExtensionProvider(
() => workspaceActionsRef.current,
@ -950,7 +984,7 @@ export function useAtMentionMenu({
...builtinAtProviders,
...getRegisteredCustomProviders(providers),
].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}, [builtinProviders, providers, t, workspaceActionsRef]);
}, [builtinProviders, providers, t, workspaceActionsRef, onUploadRequest]);
const allProvidersRef = useRef(allProviders);
allProvidersRef.current = allProviders;
@ -1182,8 +1216,11 @@ export function useAtMentionMenu({
}
setMenu((prev) => {
if (!prev || prev.level !== 'items') return prev;
// The file provider prepends prefix items whenever its entry
// query is empty (e.g. `@src/`), not only for `query === ''`;
// filtered queries are already capped inside the provider.
const maxItems =
providerId === FILE_PROVIDER_ID && query.length === 0
providerId === FILE_PROVIDER_ID
? FILE_ROOT_ITEM_LIMIT
: ITEM_LIMIT;
return {
@ -1819,6 +1856,49 @@ export function useAtMentionMenu({
if (current.selectedProviderId) {
lastSelectedProviderIdRef.current = current.selectedProviderId;
}
if (item.kind === 'upload') {
// Menu items are not recomputed when upload availability changes,
// so a stale item can be accepted after the handler disappears;
// dropping the typed query in that case would silently eat it.
if (disabledRef.current || !onUploadRequest) {
close();
return true;
}
const removedText = view.state.sliceDoc(current.from, current.to);
view.dispatch({
changes: { from: current.from, to: current.to, insert: '' },
selection: { anchor: current.from },
});
const docAfterRemoval = view.state.doc;
// Typed queries (`@src/`) browse a directory derived from the query
// text without syncing fileDirectoryRef, so re-derive the same
// directory the panel is displaying.
const { dirPath } = splitFileQuery(
current.query,
fileDirectoryRef.current,
);
onUploadRequest(dirPath, () => {
const doc = view.state.doc;
// Tolerate a pure end-append — the upload flow's own completed
// `@file` tags land there while the picker is open and never move
// the insertion point. Whole-doc session swaps flush this callback
// before the swap, so a foreign doc never reaches here.
if (
current.from > doc.length ||
doc.length < docAfterRemoval.length ||
doc.sliceString(0, docAfterRemoval.length) !==
docAfterRemoval.toString()
) {
return;
}
view.dispatch({
changes: { from: current.from, insert: removedText },
selection: { anchor: current.from + removedText.length },
});
});
close();
return true;
}
if (
current.selectedProviderId === FILE_PROVIDER_ID &&
item.kind === 'directory' &&
@ -1918,7 +1998,9 @@ export function useAtMentionMenu({
createInlineTagEffect,
scheduleLoadItems,
scheduleLoadMcpResourceItems,
disabledRef,
viewRef,
onUploadRequest,
],
);

View file

@ -33,6 +33,7 @@ function Harness({
atWorkspaceCwd,
commands,
onImageIngestionNotice,
workspaceUploadBusy,
}: {
composerInput?: WebShellComposerInput;
onSubmit: ReturnType<typeof vi.fn>;
@ -48,6 +49,7 @@ function Harness({
atWorkspaceCwd?: string;
commands?: UseComposerCoreOptions['commands'];
onImageIngestionNotice?: UseComposerCoreOptions['onImageIngestionNotice'];
workspaceUploadBusy?: boolean;
}) {
const composer = useComposerCore({
onSubmit,
@ -62,6 +64,7 @@ function Harness({
composerInput,
composerInputVersion: composerInput ? 1 : undefined,
onImageIngestionNotice,
workspaceUploadBusy,
});
latest = composer;
@ -83,6 +86,7 @@ async function mount({
atWorkspaceCwd,
commands,
onImageIngestionNotice,
workspaceUploadBusy,
}: {
composerInput?: WebShellComposerInput;
onSubmit?: ReturnType<typeof vi.fn>;
@ -98,6 +102,7 @@ async function mount({
atWorkspaceCwd?: string;
commands?: UseComposerCoreOptions['commands'];
onImageIngestionNotice?: UseComposerCoreOptions['onImageIngestionNotice'];
workspaceUploadBusy?: boolean;
} = {}) {
container = document.createElement('div');
document.body.append(container);
@ -121,6 +126,7 @@ async function mount({
atWorkspaceCwd={currentWorkspaceCwd}
commands={commands}
onImageIngestionNotice={onImageIngestionNotice}
workspaceUploadBusy={workspaceUploadBusy}
/>
</I18nProvider>
</WebShellPortalRootContext.Provider>,
@ -268,6 +274,42 @@ describe('useComposerCore history and drafts', () => {
expect(getItem).toHaveBeenCalledTimes(readsAfterMount);
});
it('blocks submission while an external ingestion lane is busy', async () => {
const onSubmit = vi.fn();
await mount({ onSubmit, workspaceUploadBusy: true });
act(() => {
latest!.setText('review the upload');
latest!.submitText();
});
expect(latest!.canSubmit).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});
it('blocks history retry and search-match submit while an upload is busy', async () => {
const workspaceCwd = '/workspace/upload-busy';
localStorage.setItem(
getPromptHistoryStorageKey(workspaceCwd),
JSON.stringify(['previous prompt']),
);
const onSubmit = vi.fn();
await mount({
onSubmit,
sessionId: 'session-upload-busy',
atWorkspaceCwd: workspaceCwd,
workspaceUploadBusy: true,
});
act(() => latest!.retryLast());
expect(onSubmit).not.toHaveBeenCalled();
act(() => latest!.searchState.openHistorySearch());
expect(latest!.searchState.searchMatches).toEqual(['previous prompt']);
act(() => latest!.searchState.submitSearchMatch('previous prompt'));
expect(onSubmit).not.toHaveBeenCalled();
});
it('falls back to legacy prompt history until the workspace has its own history', async () => {
localStorage.setItem(
getPromptHistoryStorageKey(),
@ -1291,6 +1333,88 @@ describe('useComposerCore tags', () => {
});
expect(latest!.handle.hasAttachments()).toBe(false);
expect(latest!.hasAttachments).toBe(false);
expect(latest!.viewRef.current!.state.doc.toString()).toBe('');
expect(document.body.querySelector('.cm-placeholder')).not.toBeNull();
});
it('preserves surrounding text when an inline tag chip is removed', async () => {
await mount();
act(() => {
latest!.insertText('please review ');
latest!.addTags(
[{ id: 'orders', value: 'orders', serialized: '@orders' }],
{ placement: 'inline' },
);
latest!.insertText(' now');
});
expect(latest!.hasAttachments).toBe(true);
const removeButton = document.body.querySelector(
'button[aria-label="Remove orders"]',
) as HTMLButtonElement | null;
expect(removeButton).not.toBeNull();
act(() => {
removeButton!.click();
});
const text = latest!.viewRef.current!.state.doc.toString();
expect(text).toContain('please review');
expect(text).toContain('now');
// The chip's serialized text must be removed from the doc, not just its
// decoration — uncovered text would be submitted as plain prompt text.
expect(text).not.toContain('@orders');
expect(latest!.hasAttachments).toBe(false);
});
it('appends end-placed inline tags without stealing focus', async () => {
await mount();
act(() => {
latest!.insertText('draft text');
});
const view = latest!.viewRef.current!;
// Caret sits mid-text; an end-placement insert must still append.
act(() => {
view.dispatch({ selection: { anchor: 5 } });
});
const outside = document.createElement('button');
document.body.appendChild(outside);
outside.focus();
expect(document.activeElement).toBe(outside);
act(() => {
latest!.addTags(
[{ id: 'orders', value: 'orders', serialized: '@orders' }],
{ placement: 'inline', position: 'end' },
);
});
const doc = view.state.doc.toString();
// A boundary separates the appended reference from the preceding text,
// and the caret stays where the user left it (no teleport to doc end).
expect(doc).toBe('draft text @orders ');
expect(view.state.selection.main.from).toBe(5);
expect(document.activeElement).toBe(outside);
// The end-placed tag must become a real chip decoration: a wrong
// effect-range offset would leave the doc text correct but decorate the
// wrong span, breaking the remove button and atomic-range behavior.
expect(latest!.hasAttachments).toBe(true);
const removeButton = document.body.querySelector(
'button[aria-label="Remove orders"]',
) as HTMLButtonElement | null;
expect(removeButton).not.toBeNull();
act(() => {
removeButton!.click();
});
// The remove button deletes the chip's serialized text (separator
// spacing aside), restoring the pre-upload content.
const removed = view.state.doc.toString();
expect(removed).not.toContain('@orders');
expect(removed.trim()).toBe('draft text');
expect(latest!.hasAttachments).toBe(false);
outside.remove();
});
it('updates inline tag state when a document change removes the last tag', async () => {

View file

@ -577,6 +577,19 @@ export const removeInlineTagEffect = StateEffect.define<{
}>();
export const clearInlineTagsEffect = StateEffect.define<void>();
function normalizeInlineTagRemovalChanges(
view: EditorView,
changes: Array<{ from: number; to: number; insert: string }>,
) {
let remaining = view.state.doc.toString();
for (const change of changes.slice().reverse()) {
remaining = remaining.slice(0, change.from) + remaining.slice(change.to);
}
return remaining.trim().length === 0
? [{ from: 0, to: view.state.doc.length, insert: '' }]
: changes;
}
let nextComposerTagTooltipId = 0;
class ComposerTagWidget extends WidgetType {
@ -798,7 +811,7 @@ class ComposerTagWidget extends WidgetType {
});
if (changes.length === 0) return;
view.dispatch({
changes,
changes: normalizeInlineTagRemovalChanges(view, changes),
effects: removeInlineTagEffect.of({
predicate: (tag) => tag.id === this.tag.id,
}),
@ -1101,6 +1114,20 @@ export interface UseComposerCoreOptions {
renderComposerTagTooltip?: ComposerTagRenderer;
onComposerTagClick?: ComposerTagClickHandler;
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
/**
* Invoked when the user selects the @ panel's "Upload file" item, with the
* directory currently being browsed and a callback that re-inserts the
* removed mention query when the picker closes without an upload. The
* composer opens a file picker and uploads into that directory. When
* absent, the upload item is hidden.
*/
onFileUploadRequest?: (targetDir: string, restoreQuery?: () => void) => void;
/**
* True while a workspace file upload is pending or in flight. Gates
* submit exactly like the image lane's pending batches, so a prompt cannot
* go out before the upload's `@file` reference has been inserted.
*/
workspaceUploadBusy?: boolean;
/** CodeMirror theme extension for the editor view. Each variant provides its own. */
editorTheme: Parameters<typeof EditorView.theme>[0];
}
@ -1299,6 +1326,7 @@ export interface UseComposerCoreReturn {
canSubmit: boolean;
pendingImageBatchCount: number;
imageDragActive: boolean;
clearImageDragState: () => void;
imageTransferHandlers: ComposerImageTransferHandlers;
handle: EditorHandle;
pastedImages: PromptImage[];
@ -1377,6 +1405,8 @@ export function useComposerCore(
renderComposerTagTooltip,
onComposerTagClick,
onImageIngestionNotice,
onFileUploadRequest,
workspaceUploadBusy = false,
editorTheme,
} = options;
@ -1467,6 +1497,8 @@ export function useComposerCore(
onToggleShortcutsRef.current = onToggleShortcuts;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const workspaceUploadBusyRef = useRef(workspaceUploadBusy);
workspaceUploadBusyRef.current = workspaceUploadBusy;
const commandsRef = useRef(commands);
commandsRef.current = commands;
const skillsRef = useRef(skills);
@ -1591,6 +1623,7 @@ export function useComposerCore(
workspaceKey: atWorkspaceCwd,
builtinProviders: builtinAtProviders,
providers: atProviders,
onUploadRequest: onFileUploadRequest,
createInlineTagEffect: (range) =>
addInlineTagEffect.of({
...range,
@ -2379,7 +2412,8 @@ export function useComposerCore(
) => {
if (
disabledRef.current ||
imageIngestionLaneRef.current.pendingBatches > 0
imageIngestionLaneRef.current.pendingBatches > 0 ||
workspaceUploadBusyRef.current
) {
return true;
}
@ -3682,7 +3716,7 @@ export function useComposerCore(
});
if (changes.length === 0) return;
view.dispatch({
changes,
changes: normalizeInlineTagRemovalChanges(view, changes),
effects: removeInlineTagEffect.of({ predicate }),
scrollIntoView: true,
});
@ -3733,8 +3767,19 @@ export function useComposerCore(
if (tagOptions?.placement === 'inline' && !isTouchComposer) {
const view = viewRef.current;
if (!view) return;
const appendToEnd = tagOptions.position === 'end';
const selection = view.state.selection.main;
let at = selection.from;
const insertAt = appendToEnd ? view.state.doc.length : selection.from;
const replaceTo = appendToEnd ? view.state.doc.length : selection.to;
// The mention parser needs a boundary before `@`, so separate an
// appended reference from preceding non-whitespace text.
const separator =
appendToEnd &&
view.state.doc.length > 0 &&
!/\s/.test(view.state.doc.sliceString(view.state.doc.length - 1))
? ' '
: '';
let at = insertAt + separator.length;
const ranges: InlineTagRange[] = [];
const insert = tags
.map((tag) => {
@ -3744,9 +3789,9 @@ export function useComposerCore(
return tagText;
})
.join(' ');
const text = insert ? `${insert} ` : '';
const text = insert ? `${separator}${insert} ` : '';
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: text },
changes: { from: insertAt, to: replaceTo, insert: text },
effects:
ranges.length > 0
? ranges.map((range) =>
@ -3756,10 +3801,16 @@ export function useComposerCore(
}),
)
: undefined,
selection: { anchor: selection.from + text.length },
scrollIntoView: true,
// End placement serves async completions (uploads): never move the
// caret or scroll the viewport while the user types elsewhere.
selection: appendToEnd
? undefined
: { anchor: insertAt + text.length },
scrollIntoView: !appendToEnd,
});
view.focus();
// An asynchronous completion (upload) must not steal focus from
// whatever control the user moved to while it was in flight.
if (!appendToEnd || view.hasFocus) view.focus();
return;
}
setComposerTags((current) => {
@ -3842,7 +3893,8 @@ export function useComposerCore(
const retryLast = useCallback(() => {
if (
disabledRef.current ||
imageIngestionLaneRef.current.pendingBatches > 0
imageIngestionLaneRef.current.pendingBatches > 0 ||
workspaceUploadBusyRef.current
) {
return;
}
@ -4015,7 +4067,8 @@ export function useComposerCore(
(match: string) => {
if (
disabledRef.current ||
imageIngestionLaneRef.current.pendingBatches > 0
imageIngestionLaneRef.current.pendingBatches > 0 ||
workspaceUploadBusyRef.current
) {
return;
}
@ -4116,7 +4169,11 @@ export function useComposerCore(
// ---- Computed ----
const canSubmit = !disabled && pendingImageBatchCount === 0 && hasContent;
const canSubmit =
!disabled &&
pendingImageBatchCount === 0 &&
!workspaceUploadBusy &&
hasContent;
const showShortcutHints =
!shellMode &&
!searchMode &&
@ -4212,6 +4269,7 @@ export function useComposerCore(
canSubmit,
pendingImageBatchCount,
imageDragActive,
clearImageDragState,
imageTransferHandlers,
handle,
pastedImages,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,330 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
DaemonWorkspaceFileUploadRequest,
DaemonWorkspaceFileUploadResult,
} from '@qwen-code/sdk/daemon';
/**
* Minimal structural client for uploads. Both `DaemonClient`
* (legacy-primary) and `WorkspaceDaemonClient` (workspace-qualified) satisfy
* it; the caller resolves the correct target before constructing the hook.
*/
export interface FileUploadClient {
uploadWorkspaceFile(
req: DaemonWorkspaceFileUploadRequest,
clientId?: string,
): Promise<DaemonWorkspaceFileUploadResult>;
}
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error';
/** Machine-readable failure codes; the render site localizes them. */
export type FileUploadErrorCode = 'tooLarge' | 'noDaemon' | 'tooManyFiles';
export interface FileUploadItem {
id: string;
file: File;
/** Requested relative path in the target workspace. */
targetPath: string;
status: FileUploadStatus;
/** 01. */
progress: number;
/** Set for locally classified failures; localized at the render site. */
errorCode?: FileUploadErrorCode;
/** Raw failure message (server-side errors). */
error?: string;
/** Server-confirmed final path (may be auto-numbered). */
resultPath?: string;
/** Set on a `tooManyFiles` notice row: how many files were not queued. */
skippedCount?: number;
}
export interface UseFileUploadOptions {
client: FileUploadClient | undefined;
maxBytes: number;
/**
* Identity of the target workspace AND the session composing into it.
* When it changes (or the hook unmounts), in-flight uploads are aborted
* and the queue is cleared, so an upload started for workspace A cannot
* insert a path into workspace B, and an upload started in one session
* cannot append its reference to another session's draft after a switch.
*/
targetKey: string;
}
export interface UseFileUploadReturn {
uploads: FileUploadItem[];
/** True while any item is pending or in flight; gates composer submit. */
isBusy: boolean;
/**
* Queue `files` for sequential upload into `targetDir` (relative to the
* target workspace root; `'.'` for the root). `onUploaded` fires exactly
* once per successful upload with the server-confirmed final path.
* Returns how many files were actually queued (locally rejected files,
* e.g. oversized ones, become error rows without queueing).
*/
uploadFiles: (
files: File[],
targetDir: string,
onUploaded?: (path: string) => void,
) => number;
/** Remove a row and abort it if it is pending or in flight. */
removeUpload: (id: string) => void;
}
interface QueuedUpload {
id: string;
file: File;
targetPath: string;
controller: AbortController;
onUploaded?: (path: string) => void;
}
let uploadIdCounter = 0;
const SUCCESS_DISMISS_MS = 3000;
/**
* Cap on files accepted per drop/picker batch. Unbounded batches render one
* strip row per file and keep the strictly-sequential queue busy for hours;
* overflow is surfaced as a single notice row instead.
*/
const MAX_FILES_PER_BATCH = 100;
function joinTargetPath(targetDir: string, filename: string): string {
const dir = targetDir.replace(/\/+$/, '');
if (dir === '' || dir === '.') return filename;
return `${dir}/${filename}`;
}
export function useFileUpload(
options: UseFileUploadOptions,
): UseFileUploadReturn {
const { client, maxBytes, targetKey } = options;
const [uploads, setUploads] = useState<FileUploadItem[]>([]);
const uploadsRef = useRef<FileUploadItem[]>([]);
const queueRef = useRef<QueuedUpload[]>([]);
const activeUploadRef = useRef<QueuedUpload | undefined>(undefined);
const processingRef = useRef(false);
const generationRef = useRef(0);
const prevTargetKeyRef = useRef(targetKey);
// Invalidate the generation synchronously when the target changes:
// `clientRef` is reassigned during render, so an in-flight completion
// landing between commit and the reset effect below would otherwise pass
// the old-generation guard while already holding the NEW target's client.
if (prevTargetKeyRef.current !== targetKey) {
prevTargetKeyRef.current = targetKey;
generationRef.current += 1;
activeUploadRef.current?.controller.abort();
for (const queued of queueRef.current) queued.controller.abort();
}
const clientRef = useRef<FileUploadClient | undefined>(client);
clientRef.current = client;
const maxBytesRef = useRef(maxBytes);
maxBytesRef.current = maxBytes;
const commit = useCallback(() => {
setUploads([...uploadsRef.current]);
}, []);
const patchItem = useCallback(
(id: string, patch: Partial<FileUploadItem>) => {
uploadsRef.current = uploadsRef.current.map((item) =>
item.id === id ? { ...item, ...patch } : item,
);
commit();
},
[commit],
);
const processQueue = useCallback(async () => {
if (processingRef.current) return;
processingRef.current = true;
const generation = generationRef.current;
try {
while (queueRef.current.length > 0) {
if (generationRef.current !== generation) return;
const next = queueRef.current.shift();
if (!next) return;
const { id, file, targetPath, controller, onUploaded } = next;
if (controller.signal.aborted) continue;
const activeClient = clientRef.current;
if (!activeClient) {
patchItem(id, { status: 'error', errorCode: 'noDaemon' });
continue;
}
activeUploadRef.current = next;
patchItem(id, { status: 'uploading', progress: 0 });
try {
const result = await activeClient.uploadWorkspaceFile({
path: targetPath,
data: file,
signal: controller.signal,
// The caller's AbortController owns cancellation; a valid
// max-size upload can exceed the SDK's general default timeout.
timeoutMs: 0,
onProgress: (event) => {
if (generationRef.current !== generation) return;
const progress =
event.total > 0 ? Math.min(1, event.loaded / event.total) : 0;
const last =
uploadsRef.current.find((item) => item.id === id)?.progress ??
0;
// Chunk arrivals fire tens of sub-percentage events per second;
// commit only visible advances (>= 2%) and the final value so
// the composer tree does not re-render for every chunk.
if (progress < 1 && progress >= last && progress - last < 0.02) {
return;
}
patchItem(id, { progress });
},
});
if (generationRef.current !== generation) return;
if (controller.signal.aborted) continue;
patchItem(id, {
status: 'done',
progress: 1,
resultPath: result.path,
});
setTimeout(() => {
if (generationRef.current !== generation) return;
uploadsRef.current = uploadsRef.current.filter(
(item) => item.id !== id,
);
commit();
}, SUCCESS_DISMISS_MS);
try {
onUploaded?.(result.path);
} catch (err) {
console.error('Failed to handle uploaded file', err);
}
} catch (err) {
if (generationRef.current !== generation) return;
if (controller.signal.aborted) {
// Canceled/removed: drop the row. A late server publish is best
// effort and cannot be recalled, but no reference is inserted.
uploadsRef.current = uploadsRef.current.filter(
(item) => item.id !== id,
);
commit();
continue;
}
patchItem(id, {
status: 'error',
error: err instanceof Error ? err.message : String(err),
});
} finally {
if (activeUploadRef.current === next) {
activeUploadRef.current = undefined;
}
}
}
} finally {
processingRef.current = false;
// Items may have been queued while this loop was winding down.
if (queueRef.current.length > 0) void processQueue();
}
}, [commit, patchItem]);
const uploadFiles = useCallback(
(files: File[], targetDir: string, onUploaded?: (path: string) => void) => {
if (files.length === 0) return 0;
const limit = maxBytesRef.current;
const accepted = files.slice(0, MAX_FILES_PER_BATCH);
const skipped = files.length - accepted.length;
let queuedCount = 0;
for (const file of accepted) {
uploadIdCounter += 1;
const id = `upload-${uploadIdCounter}`;
const targetPath = joinTargetPath(targetDir, file.name);
if (file.size > limit) {
uploadsRef.current = [
...uploadsRef.current,
{
id,
file,
targetPath,
status: 'error',
progress: 0,
errorCode: 'tooLarge',
},
];
continue;
}
uploadsRef.current = [
...uploadsRef.current,
{ id, file, targetPath, status: 'pending', progress: 0 },
];
queueRef.current.push({
id,
file,
targetPath,
controller: new AbortController(),
onUploaded,
});
queuedCount += 1;
}
if (skipped > 0) {
uploadIdCounter += 1;
uploadsRef.current = [
...uploadsRef.current,
{
id: `upload-${uploadIdCounter}`,
file: files[accepted.length] as File,
targetPath: '',
status: 'error',
progress: 0,
errorCode: 'tooManyFiles',
skippedCount: skipped,
},
];
}
commit();
void processQueue();
return queuedCount;
},
[commit, processQueue],
);
const removeUpload = useCallback(
(id: string) => {
if (activeUploadRef.current?.id === id) {
activeUploadRef.current.controller.abort();
}
const queued = queueRef.current.find((q) => q.id === id);
if (queued) {
queued.controller.abort();
queueRef.current = queueRef.current.filter((q) => q.id !== id);
}
uploadsRef.current = uploadsRef.current.filter((item) => item.id !== id);
commit();
},
[commit],
);
// Reset the queue when the target workspace changes; abort anything in
// flight so a stale upload cannot land in (or insert into) the new target.
useEffect(() => {
generationRef.current += 1;
activeUploadRef.current?.controller.abort();
for (const queued of queueRef.current) queued.controller.abort();
queueRef.current = [];
uploadsRef.current = [];
commit();
return () => {
generationRef.current += 1;
activeUploadRef.current?.controller.abort();
for (const queued of queueRef.current) queued.controller.abort();
};
}, [targetKey, commit]);
const isBusy = uploads.some(
(item) => item.status === 'pending' || item.status === 'uploading',
);
return { uploads, isBusy, uploadFiles, removeUpload };
}

View file

@ -522,6 +522,21 @@ const EN: Messages = {
'at.category.extensions.description': 'Reference active extensions',
'at.category.files': 'Files',
'at.category.files.description': 'Reference workspace files',
'at.files.upload': 'Upload file',
'at.files.upload.description': 'Upload a file into this folder',
'composer.upload.pending': 'Waiting',
'composer.upload.uploading': 'Uploading',
'composer.upload.done': 'Uploaded',
'composer.upload.error': 'Failed',
'composer.upload.error.noDaemon': 'No daemon connection',
'composer.upload.error.tooLarge': (v) =>
`File exceeds the ${v?.limit ?? ''} upload limit`,
'composer.upload.error.tooManyFiles': (v) =>
`${v?.count ?? 0} more files were not added (limit per batch)`,
'composer.upload.cancel': 'Cancel upload',
'composer.upload.dismiss': 'Dismiss',
'composer.upload.renamed': 'Saved as',
'composer.upload.drop': 'Drop files',
'at.category.mcpResources': 'MCP resources',
'at.category.mcpResources.description': 'Reference MCP server resources',
'at.menu': 'Reference menu',
@ -3412,6 +3427,21 @@ const ZH: Messages = {
'at.category.extensions.description': '引用已启用扩展',
'at.category.files': '文件',
'at.category.files.description': '引用工作区文件',
'at.files.upload': '上传文件',
'at.files.upload.description': '上传文件到此文件夹',
'composer.upload.pending': '等待中',
'composer.upload.uploading': '上传中',
'composer.upload.done': '已上传',
'composer.upload.error': '失败',
'composer.upload.error.noDaemon': '未连接 Daemon',
'composer.upload.error.tooLarge': (v) =>
`文件超过 ${v?.limit ?? ''} 上传上限`,
'composer.upload.error.tooManyFiles': (v) =>
`另有 ${v?.count ?? 0} 个文件未添加(单批数量上限)`,
'composer.upload.cancel': '取消上传',
'composer.upload.dismiss': '关闭',
'composer.upload.renamed': '已保存为',
'composer.upload.drop': '拖放文件',
'at.category.mcpResources': 'MCP 资源',
'at.category.mcpResources.description': '引用 MCP server 资源',
'at.menu': '引用菜单',