feat(web-shell): add file-tree deletion with confirmation (#14158)

* feat(web-shell): add confirmed file deletion from file tree

* test(serve): include file deletion in capability baseline

* fix(web-shell): address file deletion review suggestions

* test(cli): synchronize child cancellation tests

* test(web-shell): refresh transcript budget after file actions

---------

Co-authored-by: 钉萁 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-16 14:41:34 +08:00
1 parent f9df3df1ef
commit 87dcc4a708
24 files changed
+1160 -44

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
# Web Shell file deletion
[English](web-shell-file-delete.md) | [简体中文](web-shell-file-delete.zh-CN.md)
## Problem and scope
The Files context menu can preview, reference, copy paths and download, but
cannot delete a file. Add permanent deletion of one regular file in a trusted
local workspace, with a second confirmation explicitly warning that deletion
cannot be undone. Directory, symlink and SSH deletion are outside this change.
The regular-file action coexists with main's separately gated directory deletion.
## Design
- Add an optional local filesystem `deleteFile(input)` operation. Resolve the
parent inside the selected workspace, preserve the lexical basename, reject
roots/directories/symlinks/other entries, and use non-recursive `unlink`.
Reuse mutation trust, generation guards, per-path locks and filesystem audit
with the original file size and ignore-rule attribution.
Revalidate the canonical parent immediately before unlink. Node's portable
path-based unlink cannot make parent replacement by an external process
fully atomic; this retains the existing filesystem mutation boundary model.
- Add only `POST /workspaces/:workspace/file/delete` behind strict mutation
authentication and trusted selected-runtime resolution. Obtain the filesystem
from that runtime; reject unsupported factories and never fall back to the
primary. Return the confirmed workspace-relative deleted path. Advertise
`workspace_file_delete` as the new contract capability, document the public
contract, and normalize delete telemetry alongside other file mutations.
- Add a workspace-qualified SDK method and an optional artifact delete action.
Require both delete and qualified REST capabilities, the exact trusted owner,
and a local (non-SSH) workspace. Recheck eligibility and authority before and
after the request so stale handlers cannot mutate a replacement owner.
Exclude mutation actions from the read-only transcript build, preserving its
existing size budget.
- Add Delete only to regular-file menus. Use the shared scoped AlertDialog,
show the absolute workspace file path and an irreversible-deletion warning,
focus Cancel by default, and require the destructive button to proceed.
Cancellation and
Escape send no request. Disable dismissal and duplicate submission while
deleting; keep errors visible in the dialog for retry.
Share the existing folder confirmation flow with a file/directory target kind,
distinct messages and methods. Wait for pending uploads before confirming.
- After success remove the file from the existing tree and clear its preview
if selected. Preserve other expansion/filter/selection state. Avoid restoring
dropdown focus over the confirmation dialog; restore tree focus when it
closes, preferring the active row with the container as fallback. Bind pending
confirmation/results to the tree/workspace lifetime.
## Files and decisions
The change reaches the CLI filesystem intent/policy/implementation, existing
workspace write-route registration and capability list; the SDK's qualified
client; and Web Shell's artifact target, Files browser and bilingual messages.
The filesystem method is optional because existing SSH/custom factories do not
support deletion. No shell command, recursive removal, trash/recovery feature,
new dependency, or legacy-primary delete route is introduced.
## Validation and acceptance
First record the global CLI baseline, falling back to latest-main browser
coverage where the installed CLI lacks Files. Test filesystem regular/binary/
empty files, directories/roots/symlinks/traversal rejection, trust and closed
runtime guards, parent replacement, canonical path locking and audit fields;
route auth, unsupported factories, selected workspace and no fallback with
same-named files in primary and secondary workspaces; SDK request
shape; stale UI actions; browser cancel/confirm/busy/failure/preview cleanup and
capability/symlink gating. Use an isolated real daemon to verify disk results.
Capture and visually inspect actual confirmation/success UI with a clearly
labeled simulated daemon, and store images separately from implementation.
Run build/typecheck/bundle, focused tests, lint/format and two clean diff audits.
## Open questions
None. Permanent deletion and the explicit second warning are user requirements.
@@ -0,0 +1,55 @@
# Web Shell 文件删除
[English](web-shell-file-delete.md) | [简体中文](web-shell-file-delete.zh-CN.md)
## 问题与范围
Files 右键菜单已有预览、引用、复制路径和下载,但无法删除文件。为可信本地工作区
添加单个普通文件的永久删除,二次确认明确提示删除后不可恢复。本次不包含目录、
符号链接和 SSH 工作区删除。
普通文件删除与 main 已有的目录删除并存,各自受独立能力门控。
## 设计
- 添加可选的本地文件系统 `deleteFile(input)` 操作。在选定工作区内解析父目录,
保留末级文件名,拒绝根目录、目录、符号链接和其他类型,仅使用非递归 `unlink`。
复用写入的信任、运行代际检查、逐路径锁及文件系统审计,记录原始文件大小和
忽略规则归因,在 unlink 前再次验证规范父目录。Node 的可移植路径 unlink 无法让外部进程替换父目录完全原子化,
本次沿用既有文件系统变更边界模型。
- 只添加 `POST /workspaces/:workspace/file/delete`,使用严格变更鉴权和可信
selected-runtime 解析。文件系统必须取自该运行时,不支持的工厂明确拒绝,
禁止回退主工作区。返回确认删除的工作区相对路径,以 `workspace_file_delete`
公布新接口能力,同步公开协议文档,并与其他文件变更统一删除请求的遥测分类。
- 添加工作区限定的 SDK 方法及可选 artifact 删除动作。要求删除和限定 REST
两项能力、准确可信的所有者,以及非 SSH 的本地工作区。请求前后重新检查能力和
所有权,防止过期处理器修改替换后的工作区所有者。
从只读 transcript 构建中排除变更动作,保留既有体积预算。
- 仅在普通文件菜单中显示删除。复用带主题作用域的 AlertDialog,显示工作区文件
绝对路径和不可恢复警告,默认聚焦取消,必须点击危险操作按钮才执行。取消和 Escape 不发起
请求。删除过程中禁止关闭和重复提交,失败提示保留在弹窗内并支持重试。
与既有文件夹确认流程共用弹窗,按文件/目录类型区分文案和接口,等待进行中的上传
完成后才允许确认。
- 成功后从既有树移除文件,若其正在预览则清除预览,保留其他展开、筛选和选中状态。
防止菜单恢复焦点抢占确认弹窗,关闭弹窗后优先恢复活动行焦点,无活动行时回退树
容器。确认及请求结果绑定到文件树和工作区生命周期。
## 文件与决策
改动涉及 CLI 文件系统 intent、策略及实现、已有工作区写路由注册和能力列表,
SDK 限定工作区客户端,以及 Web Shell artifact 目标、文件浏览器和双语文案。
文件系统方法设为可选,因为已有 SSH/自定义工厂不支持删除。不引入 shell 命令、
递归删除、回收站或恢复功能、新依赖或旧主工作区删除接口。
## 验证与验收
先记录全局 CLI 基线,已安装 CLI 没有 Files 时回退最新 main 浏览器测试。
验证普通、二进制和空文件删除,目录/根目录/符号链接/越界拒绝,信任及关闭运行代际
保护、父目录替换、规范路径锁及审计字段;路由鉴权、不支持的工厂、目标工作区且
不回退,并验证主次工作区存在同名文件时的隔离;SDK 请求形状;过期 UI 动作;浏览器取消、
确认、忙碌、失败、预览清理,以及能力和符号链接门控。使用隔离真实 daemon
确认磁盘结果。捕获并目视检查实际确认和成功界面,明确标注模拟 daemon,图片
独立于实现存储。完成构建、类型检查、打包、定向测试、lint/格式及两轮干净差异审查。
## 待定问题
无。永久删除及明确的二次警告均为用户要求。
@@ -262,16 +262,16 @@ workspace-qualified REST.
## Configuration
| Source | Knob | Effect |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `WorkspaceFileSystemFactoryDeps.trusted: boolean` | Constructor input | Whether writes, edits, and deletes 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_read_cursor`, `workspace_file_write`, `workspace_file_upload`, `workspace_directory_create`, `workspace_directory_delete` | 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, edits, and deletes 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_read_cursor`, `workspace_file_write`, `workspace_file_upload`, `workspace_file_delete`, `workspace_directory_create`, `workspace_directory_delete` | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
| Workspace files | `.gitignore`, `.qwenignore` | Ignored paths surface as `ignored: true` from `shouldIgnore`. |
## Caveats & Known Limits
@@ -131,7 +131,7 @@ Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_
Batch session catalogs: `session_catalog_batch` independently advertises `POST /sessions/catalog` for up to 20 registered workspaces, with per-workspace cursors, explicit entry errors, and optional group catalogs. The tag is unconditional, including single-workspace daemons. Discover it once and fall back to existing workspace-qualified list/group requests when absent; support cannot be inferred from `session_list`, `multi_workspace_sessions`, or `workspace_qualified_rest_core`. The batch never starts ACP and excludes internal workspaces.
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, `workspace_skill_settings_toggle`, `workspace_skill_settings_batch_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_directory_create`, `workspace_directory_delete`, **`workspace_reload`** (conditional). The two Skill settings tags replace the retired catalog-validated `workspace_skill_toggle` and `workspace_skill_batch_toggle` tags.
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, `workspace_skill_settings_toggle`, `workspace_skill_settings_batch_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_file_delete`, `workspace_directory_create`, `workspace_directory_delete`, **`workspace_reload`** (conditional). The two Skill settings tags replace the retired catalog-validated `workspace_skill_toggle` and `workspace_skill_batch_toggle` tags.
Local directory mutations: preflight `workspace_qualified_rest_core` and the respective `workspace_directory_create` or `workspace_directory_delete` tag for `POST /workspaces/:workspace/directory/create` or `POST /workspaces/:workspace/directory/delete`. The tags advertise contracts rather than authorization: both require strict mutation authority and a trusted active local runtime. Creation is exclusive under an existing parent; deletion recursively removes directory contents, including symlink entries without following their targets. There are no legacy-primary directory mutation routes. Use `WorkspaceDaemonClient.createWorkspaceDirectory` and `WorkspaceDaemonClient.deleteWorkspaceDirectory`; both use REST even when ACP transport is configured.
+4 -2
View File
@@ -227,7 +227,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', 'workspace_directory_create', 'workspace_directory_delete',
'workspace_file_upload', 'workspace_file_delete', 'workspace_directory_create', 'workspace_directory_delete',
'session_approval_mode_control', 'workspace_tool_toggle',
'workspace_skill_settings_toggle', 'workspace_skill_settings_batch_toggle',
'extension_batch_activation_v2', 'extension_activation_explicit_refresh',
@@ -364,7 +364,9 @@ 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`, `/workspaces/:workspace/file/edit`, and `/workspaces/:workspace/file/upload`.
`workspace_file_delete` covers only `POST /workspaces/:workspace/file/delete`; no legacy-primary `/file/delete` route exists. The JSON body is `{ "path": "relative/file.txt" }`, and success returns `{ "kind": "file_delete", "path": "relative/file.txt" }`. This strict mutation route permanently removes one regular file from the selected trusted local workspace. Directories and symlinks are rejected; unsupported factories (including SSH) return 422. The capability describes protocol support, not permission to delete in every workspace. Clients must also preflight `workspace_qualified_rest_core`.
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`, `/workspaces/:workspace/file/upload`, and `/workspaces/:workspace/file/delete` (the last route additionally requires `workspace_file_delete`).
`workspace_directory_create` and `workspace_directory_delete` independently advertise `POST /workspaces/:workspace/directory/create` and `POST /workspaces/:workspace/directory/delete`. Preflight the operation's tag alongside `workspace_qualified_rest_core`; older qualified daemons may lack these routes. Both are strict mutations owned by the trusted active selected local runtime, with no primary fallback or legacy-primary counterpart. Creation is exclusive under an existing parent; deletion is permanent and recursive.
@@ -456,6 +456,7 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_file_read_cursor',
'workspace_file_write',
'workspace_file_upload',
'workspace_file_delete',
'workspace_directory_create',
'workspace_directory_delete',
'session_approval_mode_control',
+3 -1
View File
@@ -223,6 +223,8 @@ export const SERVE_CAPABILITY_REGISTRY = {
// `workspace_file_read`). The advertised upload byte cap is surfaced via
// `limits.maxWorkspaceFileUploadBytes`.
workspace_file_upload: { since: 'v1' },
// Qualified-only deletion of regular files in trusted local workspaces.
workspace_file_delete: { since: 'v1' },
workspace_directory_create: { since: 'v1' },
workspace_directory_delete: { since: 'v1' },
// Daemon hosts the session-level approval-mode
@@ -492,7 +494,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
// surfacing a guaranteed launch failure.
workspace_local_terminal: { since: 'v1' },
// Workspace-qualified core REST routes under `/workspaces/:workspace/...`.
// Covers core file read/write/upload, status/permissions/trust/lifecycle/MCP/tool,
// Covers core file read/write/upload/delete, 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
@@ -24,6 +24,7 @@ import {
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import { canonicalizeWorkspace } from './paths.js';
import { isFsError } from './errors.js';
import { PathMutexRegistry } from './path-mutex-registry.js';
import { arePermissionBitsEnforced } from '../../test-utils/permission-bits.js';
interface Harness {
@@ -40,6 +41,7 @@ async function makeHarness(opts?: {
includeRawPaths?: boolean;
generationGuard?: { assertOpen(): void };
newFileMode?: NewFileModePolicy;
pathLocks?: PathMutexRegistry;
}): Promise<Harness> {
const scratch = await fsp.mkdtemp(
path.join(os.tmpdir(), `qwen-wfs-${randomBytes(4).toString('hex')}-`),
@@ -56,6 +58,7 @@ async function makeHarness(opts?: {
includeRawPaths: opts?.includeRawPaths,
generationGuard: opts?.generationGuard,
newFileMode: opts?.newFileMode,
pathLocks: opts?.pathLocks,
});
const fs = factory.forRequest({
originatorClientId: 'client-x',
@@ -3171,3 +3174,232 @@ describe('WorkspaceFileSystem - factory', () => {
}
});
});
describe('WorkspaceFileSystem - deleteFile', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness();
});
afterEach(async () => {
vi.restoreAllMocks();
await teardown(h);
});
it.each(['text', 'binary', 'empty'])(
'deletes only the requested %s file and audits the operation',
async (kind) => {
await fsp.mkdir(path.join(h.workspace, 'nested'));
const target = path.join(h.workspace, 'nested', 'file.bin');
await fsp.writeFile(
target,
kind === 'empty'
? Buffer.alloc(0)
: kind === 'binary'
? Buffer.from([0, 255, 128])
: 'text',
);
await fsp.writeFile(path.join(h.workspace, 'keep.txt'), 'keep');
expect(await h.fs.deleteFile!('nested/file.bin')).toBe(target);
expect(await fsp.stat(target).catch(() => null)).toBeNull();
expect(
await fsp.readFile(path.join(h.workspace, 'keep.txt'), 'utf8'),
).toBe('keep');
expect(
h.events.some(
(event) =>
event.type === FS_ACCESS_EVENT_TYPE &&
(event.data as { intent: string }).intent === 'delete',
),
).toBe(true);
},
);
it.each([
['.', 'parse_error', 400],
['..', 'parse_error', 400],
['', 'parse_error', 400],
['folder/', 'parse_error', 400],
['folder', 'parse_error', 400],
['a\0b.txt', 'parse_error', 400],
['file.', 'parse_error', 400],
['../outside.txt', 'path_outside_workspace', 400],
['missing.txt', 'path_not_found', 404],
] as const)(
'refuses target %j with %s (%i)',
async (target, kind, status) => {
await fsp.mkdir(path.join(h.workspace, 'folder'));
await fsp.writeFile(path.join(h.workspace, 'folder', 'keep.txt'), 'keep');
await fsp.writeFile(path.join(h.scratch, 'outside.txt'), 'outside');
await expect(h.fs.deleteFile!(target)).rejects.toMatchObject({
kind,
status,
});
expect(
await fsp.readFile(
path.join(h.workspace, 'folder', 'keep.txt'),
'utf8',
),
).toBe('keep');
expect(
await fsp.readFile(path.join(h.scratch, 'outside.txt'), 'utf8'),
).toBe('outside');
},
);
it('records ignore attribution and original size for deleted files', async () => {
await teardown(h);
h = await makeHarness({ ignore: new Ignore().add('*.log') });
await fsp.writeFile(path.join(h.workspace, 'ignored.log'), 'audit');
await h.fs.deleteFile!('ignored.log');
expect(
h.events.find((event) => event.type === FS_ACCESS_EVENT_TYPE)?.data,
).toMatchObject({
intent: 'delete',
matchedIgnore: 'file',
sizeBytes: 5,
});
});
it('waits on the canonical path lock used by other mutations', async () => {
await teardown(h);
const pathLocks = new PathMutexRegistry();
h = await makeHarness({ pathLocks });
const target = path.join(h.workspace, 'keep.txt');
await fsp.writeFile(target, 'keep');
let release!: () => void;
const hold = new Promise<void>((resolve) => {
release = resolve;
});
const lock = vi.spyOn(pathLocks, 'runExclusive');
const held = pathLocks.runExclusive(target, () => hold);
const unlink = vi.spyOn(fsp, 'unlink');
const deletion = h.fs.deleteFile!('./keep.txt');
try {
await vi.waitFor(() => expect(lock).toHaveBeenCalledTimes(2));
expect(lock).toHaveBeenLastCalledWith(target, expect.any(Function));
expect(unlink).not.toHaveBeenCalled();
expect(await fsp.readFile(target, 'utf8')).toBe('keep');
} finally {
release();
await held;
await deletion;
}
await expect(fsp.stat(target)).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each(['lock acquisition', 'metadata read'])(
'refuses a parent replaced after %s without deleting either file',
async (phase) => {
await teardown(h);
const pathLocks = new PathMutexRegistry();
h = await makeHarness({ pathLocks });
const parent = path.join(h.workspace, 'nested');
const saved = path.join(h.workspace, 'saved');
const outside = path.join(h.scratch, 'outside');
await fsp.mkdir(parent);
await fsp.mkdir(outside);
await fsp.writeFile(path.join(parent, 'keep.txt'), 'inside');
await fsp.writeFile(path.join(outside, 'keep.txt'), 'outside');
const swap = async () => {
await fsp.rename(parent, saved);
await fsp.symlink(outside, parent, 'dir');
};
const lstat = fsp.lstat;
const metadata = vi.spyOn(fsp, 'lstat');
if (phase === 'lock acquisition') {
const runExclusive = pathLocks.runExclusive.bind(pathLocks);
vi.spyOn(pathLocks, 'runExclusive').mockImplementation((key, fn) =>
runExclusive(key, async () => {
await swap();
return fn();
}),
);
} else {
metadata.mockImplementation(async (...args) => {
const stat = await lstat(...args);
if (args[0] === path.join(parent, 'keep.txt')) await swap();
return stat;
});
}
await expect(h.fs.deleteFile!('nested/keep.txt')).rejects.toMatchObject({
kind: 'symlink_escape',
});
if (phase === 'lock acquisition') {
expect(metadata).not.toHaveBeenCalledWith(
path.join(parent, 'keep.txt'),
);
}
expect(await fsp.readFile(path.join(saved, 'keep.txt'), 'utf8')).toBe(
'inside',
);
expect(await fsp.readFile(path.join(outside, 'keep.txt'), 'utf8')).toBe(
'outside',
);
},
);
it('rejects final symlinks without removing either link or target', async () => {
const target = path.join(h.workspace, 'target.txt');
await fsp.writeFile(target, 'keep');
await fsp.symlink(target, path.join(h.workspace, 'link'));
await expect(h.fs.deleteFile!('link')).rejects.toMatchObject({
kind: 'symlink_escape',
});
expect(
(await fsp.lstat(path.join(h.workspace, 'link'))).isSymbolicLink(),
).toBe(true);
expect(await fsp.readFile(target, 'utf8')).toBe('keep');
});
it('rejects a symlink parent escaping the workspace', async () => {
const outside = path.join(h.scratch, 'outside');
await fsp.mkdir(outside);
await fsp.writeFile(path.join(outside, 'keep.txt'), 'keep');
await fsp.symlink(outside, path.join(h.workspace, 'link'), 'dir');
await expect(h.fs.deleteFile!('link/keep.txt')).rejects.toMatchObject({
kind: 'symlink_escape',
});
expect(await fsp.readFile(path.join(outside, 'keep.txt'), 'utf8')).toBe(
'keep',
);
expect(
h.events.filter((event) => event.type === FS_DENIED_EVENT_TYPE),
).toHaveLength(1);
});
it('rejects untrusted workspaces before deletion', async () => {
await teardown(h);
h = await makeHarness({ trusted: false });
const target = path.join(h.workspace, 'keep.txt');
await fsp.writeFile(target, 'keep');
await expect(h.fs.deleteFile!('keep.txt')).rejects.toMatchObject({
kind: 'untrusted_workspace',
});
expect(await fsp.readFile(target, 'utf8')).toBe('keep');
});
it('rechecks generation after metadata work and before unlink', async () => {
await teardown(h);
let closed = false;
const failure = Object.assign(new Error('generation closed'), {
code: 'workspace_generation_closed',
});
h = await makeHarness({
generationGuard: {
assertOpen() {
if (closed) throw failure;
},
},
});
const target = path.join(h.workspace, 'keep.txt');
await fsp.writeFile(target, 'keep');
const original = fsp.lstat;
vi.spyOn(fsp, 'lstat').mockImplementation(async (...args) => {
const stat = await original(...args);
if (args[0] === target) closed = true;
return stat;
});
await expect(h.fs.deleteFile!('keep.txt')).rejects.toBe(failure);
expect(await fsp.readFile(target, 'utf8')).toBe('keep');
});
});
@@ -282,6 +282,8 @@ export interface SameHostToolTextWriteRequest {
* threaded through every operation.
*/
export interface WorkspaceFileSystem {
/** Permanent regular-file deletion; absent on SSH/custom factories. */
deleteFile?(input: string): Promise<ResolvedPath>;
resolve(input: string, intent: Intent): Promise<ResolvedPath>;
stat(p: ResolvedPath): Promise<FsStat>;
readText(
@@ -1623,6 +1625,76 @@ class WorkspaceFileSystemImpl implements WorkspaceFileSystem {
}
}
async deleteFile(input: string): Promise<ResolvedPath> {
const start = performance.now();
try {
this.deps.generationGuard?.assertOpen();
assertTrustedForIntent(this.deps.trusted, 'delete');
const basename = path.basename(input);
if (
!input ||
input.includes('\0') ||
hasSuspiciousPathPattern(input) ||
input.endsWith('/') ||
input.endsWith(path.sep) ||
basename === '.' ||
basename === '..'
) {
throw new FsError(
'parse_error',
'Deletion requires a regular file path',
);
}
// Resolve only the parent: resolving the leaf would follow symlinks.
const parent = await resolveWithinWorkspace(
path.dirname(input),
this.deps.workspaces.map((workspace) => workspace.path),
'delete',
);
const target = path.join(parent, basename) as ResolvedPath;
if (
!this.workspaceForPath(target) ||
this.deps.workspaces.some((workspace) => workspace.path === target)
) {
throw new FsError(
'path_outside_workspace',
'Cannot delete a workspace root',
);
}
return await this.deps.pathLocks.runExclusive(target, async () => {
this.deps.generationGuard?.assertOpen();
assertTrustedForIntent(this.deps.trusted, 'delete');
if ((await fsp.realpath(parent)) !== parent) {
throw new FsError('symlink_escape', 'Deletion parent changed');
}
const stat = await fsp.lstat(target);
if (!stat.isFile()) {
throw new FsError(
stat.isSymbolicLink() ? 'symlink_escape' : 'parse_error',
'Only regular files can be deleted',
);
}
if ((await fsp.realpath(parent)) !== parent) {
throw new FsError('symlink_escape', 'Deletion parent changed');
}
this.deps.generationGuard?.assertOpen();
assertTrustedForIntent(this.deps.trusted, 'delete');
const verdict = this.ignoreVerdict(target, 'file');
await fsp.unlink(target);
this.deps.audit.recordAccess(this.deps.ctx, {
intent: 'delete',
absolute: target,
durationMs: performance.now() - start,
sizeBytes: stat.size,
matchedIgnore: verdict.ignored ? verdict.category : undefined,
});
return target;
});
} catch (err) {
throw this.recordAndWrap(err, 'delete', input);
}
}
async deleteDirectory(input: string): Promise<ResolvedPath> {
const start = performance.now();
try {
@@ -223,6 +223,16 @@ async function executeAgent(
);
}
function cancelWhenChildWaits(abort: AbortController): void {
// Acceptance polling starts only after child admission has committed.
const readAcceptance = children.acceptance.bind(children);
vi.spyOn(children, 'acceptance').mockImplementationOnce((childRunId) => {
const acceptance = readAcceptance(childRunId);
abort.abort();
return acceptance;
});
}
beforeEach(async () => {
vi.resetAllMocks();
for (const method of [
@@ -1341,7 +1351,7 @@ it('a cancelled turn abandons the answer but never the committed child', async (
const turn = createTurn();
const abort = new AbortController();
const childRunId = `prompt:call-1`;
setTimeout(() => abort.abort(), 200);
cancelWhenChildWaits(abort);
const responses = await executeAgent(
turn,
call({
@@ -1364,7 +1374,7 @@ it('a cancelled turn abandons the answer but never the committed child', async (
it('a cancelled turn never admits children queued behind its abandoned wait', async () => {
const turn = createTurn();
const abort = new AbortController();
setTimeout(() => abort.abort(), 200);
cancelWhenChildWaits(abort);
const batch = await turn.execute(
[
call(
@@ -18,6 +18,7 @@ import { createServeApp } from '../server.js';
import {
canonicalizeWorkspace,
createWorkspaceFileSystemFactory,
type WorkspaceFileSystemFactory,
} from '../fs/index.js';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import type { ServeOptions } from '../types.js';
@@ -32,6 +33,7 @@ interface Harness {
workspace: string;
scratch: string;
events: BridgeEvent[];
fsFactory: WorkspaceFileSystemFactory;
app: ReturnType<typeof createServeApp>;
}
@@ -66,9 +68,9 @@ async function makeHarness(opts?: {
hostname: opts?.hostname ?? baseOpts.hostname,
},
undefined,
{ fsFactory },
{ fsFactory, primaryWorkspaceTrusted: opts?.trusted ?? true },
);
return { workspace, scratch, events, app };
return { workspace, scratch, events, app, fsFactory };
}
async function teardown(h: Harness): Promise<void> {
@@ -1566,3 +1568,93 @@ describe('fileUploadBodyParser 413 (oversized buffered body)', () => {
expect(res.text).not.toContain('file_too_large');
});
});
describe('POST /workspaces/:workspace/file/delete', () => {
let h: Harness;
beforeEach(async () => {
h = await makeHarness({ token: 'secret' });
});
afterEach(async () => teardown(h));
const endpoint = (workspace: string) =>
`/workspaces/${encodeURIComponent(workspace)}/file/delete`;
it('deletes a regular file through the qualified runtime', async () => {
await fsp.writeFile(path.join(h.workspace, 'file.txt'), 'delete me');
const res = await request(h.app)
.post(endpoint(h.workspace))
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'file.txt' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ kind: 'file_delete', path: 'file.txt' });
expect(
await fsp.stat(path.join(h.workspace, 'file.txt')).catch(() => null),
).toBeNull();
});
it('requires mutation auth and never deletes on an unknown workspace', async () => {
const target = path.join(h.workspace, 'keep.txt');
await fsp.writeFile(target, 'keep');
const denied = await request(h.app)
.post(endpoint(h.workspace))
.set('Host', loopbackHost())
.send({ path: 'keep.txt' });
expect(denied.status).toBe(401);
const unknown = await request(h.app)
.post(endpoint('unknown'))
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'keep.txt' });
expect(unknown.status).toBe(400);
expect(await fsp.readFile(target, 'utf8')).toBe('keep');
});
it('returns 422 without deleting when the runtime factory lacks delete support', async () => {
const target = path.join(h.workspace, 'keep.txt');
await fsp.writeFile(target, 'keep');
const fs = h.fsFactory.forRequest({ route: 'TEST /unsupported' });
fs.deleteFile = undefined;
h.fsFactory.forRequest = () => fs;
const res = await request(h.app)
.post(endpoint(h.workspace))
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: 'keep.txt' });
expect(res.status).toBe(422);
expect(res.body.errorKind).toBe('parse_error');
expect(await fsp.readFile(target, 'utf8')).toBe('keep');
});
it('rejects invalid bodies, directories and symlinks', async () => {
await fsp.mkdir(path.join(h.workspace, 'folder'));
await fsp.writeFile(path.join(h.workspace, 'keep.txt'), 'keep');
await fsp.symlink(
path.join(h.workspace, 'keep.txt'),
path.join(h.workspace, 'link'),
);
for (const [filePath, status, errorKind] of [
[null, 400, 'parse_error'],
['', 400, 'parse_error'],
['.', 400, 'parse_error'],
['folder', 400, 'parse_error'],
['link', 400, 'symlink_escape'],
['a\0b.txt', 400, 'parse_error'],
['file.', 400, 'parse_error'],
['missing.txt', 404, 'path_not_found'],
]) {
const res = await request(h.app)
.post(endpoint(h.workspace))
.set('Host', loopbackHost())
.set('Authorization', 'Bearer secret')
.send({ path: filePath });
expect(res.status).toBe(status);
expect(res.body.errorKind).toBe(errorKind);
}
expect(await fsp.readFile(path.join(h.workspace, 'keep.txt'), 'utf8')).toBe(
'keep',
);
expect(
(await fsp.lstat(path.join(h.workspace, 'link'))).isSymbolicLink(),
).toBe(true);
});
});
@@ -341,6 +341,41 @@ async function handlePostFileEdit(
}
}
async function handlePostFileDelete(
req: Request,
res: Response,
deps: RegisterDeps,
): Promise<void> {
const ROUTE = 'POST /workspaces/:workspace/file/delete';
// Deletion must use the resolved runtime, never the primary fallback.
const factory = getWorkspaceRouteContext(req)!.runtime.routeFileSystemFactory;
const filePath = requireBodyString(deps.safeBody(req), 'path', res, ROUTE);
if (filePath === null) return;
const clientId = deps.parseClientId(req, res);
if (clientId === null) return;
const originatorClientId = resolveOriginatorClientId(
clientId,
deps,
res,
req,
);
if (originatorClientId === null) return;
const fs = factory.forRequest({ originatorClientId, route: ROUTE });
try {
if (!fs.deleteFile)
throw new FsError(
'parse_error',
'File deletion is unavailable for this workspace',
{ status: 422 },
);
const deleted = await fs.deleteFile(filePath);
applyReadHeaders(res);
res.json({ kind: 'file_delete', path: workspaceRelative(req, deleted) });
} catch (err) {
sendFsError(res, err, ROUTE);
}
}
export function registerWorkspaceFileWriteRoutes(
app: Application,
deps: RegisterDeps,
@@ -371,6 +406,14 @@ export function registerWorkspaceQualifiedFileWriteRoutes(
});
return true;
};
app.post(
'/workspaces/:workspace/file/delete',
deps.mutate({ strict: true }),
(req, res) => {
if (!resolve(req, res)) return;
void handlePostFileDelete(req, res, deps);
},
);
app.post(
'/workspaces/:workspace/directory/delete',
deps.mutate({ strict: true }),
+1
View File
@@ -707,6 +707,7 @@ const EXPECTED_STAGE1_FEATURES = [
'workspace_file_write',
// Binary file upload (never overwrites; auto-numbers occupied names).
'workspace_file_upload',
'workspace_file_delete',
'workspace_directory_create',
'workspace_directory_delete',
// Mutation control routes (approval mode, workspace tool/skill toggles,
+20 -17
View File
@@ -681,23 +681,26 @@ 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.each(['upload', 'delete'])(
'normalizes the plural workspace %s route to a stable route label',
(operation) => {
const mw = daemonTelemetryMiddleware(() => '/ws');
const res = mockRes(200);
mw(
mockReq('POST', `/workspaces/ws-secondary/file/${operation}`),
res,
vi.fn() as unknown as NextFunction,
);
res.emit('finish');
expect(coreMocks.withDaemonRequestSpan).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'POST',
route: `POST /workspace/file/${operation}`,
}),
expect.any(Function),
);
},
);
it('attributes plural workspace voice requests to the selected workspace', () => {
const mw = daemonTelemetryMiddleware(() => '/workspace/secondary');
@@ -742,6 +742,7 @@ export function resolveDaemonTelemetryRoute(
suffix === '/workspace/file/write' ||
suffix === '/workspace/file/edit' ||
suffix === '/workspace/file/upload' ||
suffix === '/workspace/file/delete' ||
suffix === '/workspace/mcp/servers' ||
suffix === '/workspace/memory' ||
suffix === '/workspace/agents' ||
@@ -430,6 +430,29 @@ async function makeWindowsSelectorHarness() {
return { app, scratch, windowsCwd };
}
describe('workspace-qualified file deletion isolation', () => {
it('deletes only the secondary file and preserves the primary namesake', async () => {
const h = await makeHarness({ token: 'secret' });
try {
const res = await request(h.app)
.post(`/workspaces/${encodeURIComponent(h.secondaryId)}/file/delete`)
.set('Host', host())
.set('Authorization', 'Bearer secret')
.send({ path: 'target.txt' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ kind: 'file_delete', path: 'target.txt' });
await expect(
fsp.stat(path.join(h.secondaryCwd, 'target.txt')),
).rejects.toMatchObject({ code: 'ENOENT' });
expect(
await fsp.readFile(path.join(h.primaryCwd, 'target.txt'), 'utf8'),
).toBe('primary');
} finally {
await fsp.rm(h.scratch, { recursive: true, force: true });
}
});
});
describe('workspace-qualified core REST', () => {
it('routes file reads to the workspace selected by id', async () => {
const h = await makeHarness();
@@ -8806,6 +8806,18 @@ export class WorkspaceDaemonClient {
);
}
async deleteWorkspaceFile(
filePath: string,
clientId?: string,
): Promise<void> {
await this.client.workspaceJsonRequest(
this.workspaceSelector,
'/file/delete',
'POST /workspaces/:workspace/file/delete',
{ method: 'POST', body: { path: filePath }, clientId, mode: 'rest' },
);
}
private get<T>(path: string, label: string, clientId?: string): Promise<T> {
return this.client.workspaceJsonRequest<T>(
this.workspaceSelector,
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { DaemonClient } from '../../src/daemon/DaemonClient.js';
describe('workspace-qualified file deletion', () => {
it('sends an authenticated REST mutation to the exact workspace', async () => {
const fetch = vi.fn(
async () =>
new Response(
JSON.stringify({ kind: 'file_delete', path: 'notes draft.md' }),
{ headers: { 'content-type': 'application/json' } },
),
);
const client = new DaemonClient({
baseUrl: 'http://daemon',
token: 'secret',
fetch,
});
await client
.workspaceByCwd('/work with space')
.deleteWorkspaceFile('notes draft.md', 'viewer');
expect(fetch).toHaveBeenCalledTimes(1);
const [url, options] = fetch.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe(
'http://daemon/workspaces/%2Fwork%20with%20space/file/delete',
);
expect(options.method).toBe('POST');
expect(JSON.parse(String(options.body))).toEqual({
path: 'notes draft.md',
});
expect(new Headers(options.headers).get('authorization')).toBe(
'Bearer secret',
);
expect(new Headers(options.headers).get('x-qwen-client-id')).toBe('viewer');
});
it('propagates refusal rather than treating it as deletion', async () => {
const fetch = vi.fn(
async () =>
new Response(JSON.stringify({ error: 'Deletion denied' }), {
status: 403,
headers: { 'content-type': 'application/json' },
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.workspaceByCwd('/work').deleteWorkspaceFile('keep.txt'),
).rejects.toThrow('Deletion denied');
});
});
@@ -534,6 +534,7 @@ describe('build artifact — transcript entry (#11031)', () => {
'writeWorkspaceFile',
'createWorkspaceDirectory',
'deleteWorkspaceDirectory',
'deleteWorkspaceFile',
'uploadWorkspaceFile',
]) {
expect(packaged).toContain(method);
@@ -559,6 +560,10 @@ describe('build artifact — transcript entry (#11031)', () => {
'files.sourceTooLarge',
] as const)
expect(bundle).not.toContain(FILES_MESSAGES_EN[key]);
for (const key of Object.keys(FILES_MESSAGES_EN)) {
expect(bundle).not.toContain(`"${key}"`);
expect(bundle).not.toContain(`'${key}'`);
}
});
it('carries no Live Voice strings and looks none up', () => {
@@ -40,6 +40,7 @@ const {
fileStat: vi.fn(),
dirList: vi.fn(),
uploadWorkspaceFile: vi.fn(),
deleteWorkspaceFile: vi.fn(),
writeWorkspaceFile: vi.fn(),
createWorkspaceDirectory: vi.fn(),
deleteWorkspaceDirectory: vi.fn(),
@@ -464,6 +465,7 @@ afterEach(() => {
mockSecondaryWorkspaceActions.fileStat.mockReset();
mockSecondaryWorkspaceActions.dirList.mockReset();
mockSecondaryWorkspaceActions.uploadWorkspaceFile.mockReset();
mockSecondaryWorkspaceActions.deleteWorkspaceFile.mockReset();
mockSecondaryWorkspaceActions.writeWorkspaceFile.mockReset();
mockSecondaryWorkspaceActions.createWorkspaceDirectory.mockReset();
mockSecondaryWorkspaceActions.deleteWorkspaceDirectory.mockReset();
@@ -656,6 +658,105 @@ describe('ArtifactPanel web previews', () => {
});
describe('artifact workspace authority', () => {
it.each(['/primary', '/secondary'])(
'deletes through the qualified advertised owner for %s',
async (cwd) => {
mockWorkspace.capabilities.features = [
'workspace_file_delete',
'workspace_qualified_rest_core',
];
mockSecondaryWorkspaceActions.deleteWorkspaceFile.mockResolvedValue(
undefined,
);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() =>
root.render(
<ArtifactWorkspaceTargetProbe revision={0} workspaceCwd={cwd} />,
),
);
await latestArtifactWorkspaceTarget!.actions.deleteWorkspaceFile!(
'nested/file.txt',
);
expect(mockWorkspace.client.workspaceByCwd).toHaveBeenCalledWith(cwd);
expect(
mockSecondaryWorkspaceActions.deleteWorkspaceFile,
).toHaveBeenCalledWith('nested/file.txt');
},
);
it.each(['missing-delete', 'missing-rest', 'ssh'])(
'does not expose unsupported deletion: %s',
(reason) => {
mockWorkspace.capabilities.features =
reason === 'missing-delete'
? ['workspace_qualified_rest_core']
: reason === 'missing-rest'
? ['workspace_file_delete']
: ['workspace_file_delete', 'workspace_qualified_rest_core'];
if (reason === 'ssh')
mockWorkspace.capabilities = {
...mockWorkspace.capabilities,
workspaces: mockWorkspace.capabilities.workspaces.map((entry) =>
entry.cwd === '/secondary'
? { ...entry, ssh: { host: 'remote', directory: '/remote' } }
: entry,
),
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={0} />));
expect(
latestArtifactWorkspaceTarget!.actions.deleteWorkspaceFile,
).toBeUndefined();
},
);
it.each(['capability', 'owner'])(
'rejects captured and pending deletion after %s revocation',
async (reason) => {
mockWorkspace.capabilities.features = [
'workspace_file_delete',
'workspace_qualified_rest_core',
];
let finish!: () => void;
mockSecondaryWorkspaceActions.deleteWorkspaceFile.mockReturnValue(
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={0} />));
const remove =
latestArtifactWorkspaceTarget!.actions.deleteWorkspaceFile!;
const pending = remove('file.txt');
mockWorkspace.capabilities = {
...mockWorkspace.capabilities,
...(reason === 'capability'
? { features: [] }
: {
workspaces: mockWorkspace.capabilities.workspaces.filter(
(entry) => entry.cwd !== '/secondary',
),
}),
};
act(() => root.render(<ArtifactWorkspaceTargetProbe revision={1} />));
finish();
await expect(pending).rejects.toThrow(/no longer available/);
await expect(remove('file.txt')).rejects.toThrow(/no longer available/);
expect(
mockSecondaryWorkspaceActions.deleteWorkspaceFile,
).toHaveBeenCalledTimes(1);
},
);
it.each(['/primary', '/secondary'])(
'uploads only through the qualified advertised owner for %s',
async (cwd) => {
@@ -138,6 +138,7 @@ export function WorkspaceFileBrowser({
}>();
const deletedRef = useRef<((path: string) => void) | undefined>(undefined);
const [deletePath, setDeletePath] = useState<string>();
const [deleteKind, setDeleteKind] = useState<'file' | 'directory'>('file');
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string>();
const [createName, setCreateName] = useState('');
@@ -358,7 +359,7 @@ export function WorkspaceFileBrowser({
const child = `${path}/`;
deletedPaths.add(path);
for (const key of entries.keys()) {
if (key === child || key.startsWith(child)) {
if (key === path || key === child || key.startsWith(child)) {
entries.delete(key);
createdEntries.delete(key);
}
@@ -370,6 +371,7 @@ export function WorkspaceFileBrowser({
}
}
if (tree.getItem(child)) tree.remove(child, { recursive: true });
else if (tree.getItem(path)) tree.remove(path);
const parent = path.split('/').slice(0, -1).join('/');
const directory = cache.get(parent);
if (directory)
@@ -610,17 +612,23 @@ export function WorkspaceFileBrowser({
};
const confirmDelete = async () => {
if (!deletePath || deleting || isBusy || !actions.deleteDirectory) return;
const remove =
deleteKind === 'directory'
? actions.deleteDirectory
: actions.deleteWorkspaceFile;
if (!deletePath || deleting || isBusy || !remove) return;
const path = deletePath;
const lifetime = lifetimeRef.current;
setDeleting(true);
setDeleteError(undefined);
try {
await actions.deleteDirectory(path);
await remove(path);
if (lifetimeRef.current !== lifetime) return;
deletedRef.current?.(path);
setSelectedPath((selected) =>
selected?.startsWith(`${path}/`) ? undefined : selected,
selected === path || selected?.startsWith(`${path}/`)
? undefined
: selected,
);
setDeletePath(undefined);
} catch (error) {
@@ -723,6 +731,7 @@ export function WorkspaceFileBrowser({
const path = menu.path.replace(/\/$/, '');
void runMenuAction(() => {
setDeleteError(undefined);
setDeleteKind('directory');
setDeletePath(path);
}, true);
}}
@@ -783,6 +792,22 @@ export function WorkspaceFileBrowser({
>
{t('common.download')}
</DropdownMenuItem>
{menu.kind === 'file' && actions.deleteWorkspaceFile && (
<DropdownMenuItem
variant="destructive"
onSelect={() => {
captureDialogFocus();
const path = menu.path;
void runMenuAction(() => {
setDeleteError(undefined);
setDeleteKind('file');
setDeletePath(path);
}, true);
}}
>
{t('files.deleteMenu')}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
@@ -883,11 +908,25 @@ export function WorkspaceFileBrowser({
}}
>
<AlertDialogHeader>
<AlertDialogTitle>{t('files.deleteFolderTitle')}</AlertDialogTitle>
<AlertDialogTitle>
{t(
deleteKind === 'directory'
? 'files.deleteFolderTitle'
: 'files.deleteTitle',
)}
</AlertDialogTitle>
<AlertDialogDescription>
{t('files.deleteFolderWarning')}
{t(
deleteKind === 'directory'
? 'files.deleteFolderWarning'
: 'files.deleteWarning',
)}
<span className="mt-2 block break-all font-medium text-foreground">
{safeDisplayText(deletePath)}
{safeDisplayText(
deleteKind === 'file'
? absolutePath(deletePath ?? '')
: deletePath,
)}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -908,7 +947,13 @@ export function WorkspaceFileBrowser({
void confirmDelete();
}}
>
{t(deleting ? 'files.deleting' : 'files.deleteMenu')}
{t(
deleting
? 'files.deleting'
: deleteKind === 'file'
? 'files.delete'
: 'files.deleteMenu',
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,4 +1,8 @@
export const FILES_MESSAGES_EN = {
'files.delete': 'Delete file',
'files.deleteTitle': 'Delete file?',
'files.deleteWarning':
'This will permanently delete the file. This cannot be undone.',
'files.copyRelativePath': 'Copy relative path',
'files.copyAbsolutePath': 'Copy absolute path',
'files.reference': 'Reference',
@@ -33,6 +37,9 @@ export const FILES_MESSAGES_EN = {
};
export const FILES_MESSAGES_ZH = {
'files.delete': '删除文件',
'files.deleteTitle': '删除文件?',
'files.deleteWarning': '此操作将永久删除该文件。删除后不可恢复。',
'files.copyRelativePath': '复制相对路径',
'files.copyAbsolutePath': '复制绝对路径',
'files.reference': '引用',
@@ -1,6 +1,7 @@
import type {
DaemonCapabilities,
DaemonWorkspaceCapability,
WorkspaceDaemonClient,
} from '@qwen-code/sdk/daemon';
import {
useWorkspace,
@@ -22,6 +23,7 @@ export type ArtifactWorkspaceActions = Pick<
| 'updateScheduledTask'
| 'deleteScheduledTask'
> &
Partial<Pick<WorkspaceDaemonClient, 'deleteWorkspaceFile'>> &
Partial<FileUploadClient> & {
createFile?(path: string): Promise<void>;
createDirectory?(path: string): Promise<void>;
@@ -112,6 +114,13 @@ export function useArtifactWorkspaceTarget(
(entry) => entry.id === owner.id,
)?.ssh,
);
const canDelete =
localOwner &&
Boolean(
workspace.capabilities?.features?.includes('workspace_file_delete'),
);
const deleteAllowedRef = useRef(canDelete);
deleteAllowedRef.current = canDelete;
const canCreateFile =
localOwner &&
Boolean(workspace.capabilities?.features?.includes('workspace_file_write'));
@@ -210,6 +219,25 @@ export function useArtifactWorkspaceTarget(
requireCreationOwner();
};
return {
...(canDelete
? {
async deleteWorkspaceFile(path: string) {
const current = requireOwner();
if (!deleteAllowedRef.current)
throw new Error(
'Workspace file deletion is no longer available',
);
await workspace.client
.workspaceByCwd(current.cwd)
.deleteWorkspaceFile(path);
requireOwner();
if (!deleteAllowedRef.current)
throw new Error(
'Workspace file deletion is no longer available',
);
},
}
: {}),
...(canCreateFile
? { createFile: (path: string) => create('file', path) }
: {}),
@@ -317,6 +345,7 @@ export function useArtifactWorkspaceTarget(
authority,
primaryActions,
workspace.client,
canDelete,
canCreateFile,
canCreateDirectory,
canDeleteDirectory,
@@ -19,6 +19,7 @@ async function openFiles(
creationEnabled = false,
deletionEnabled = false,
uploadEnabled = false,
fileDeletionEnabled = false,
) {
const scenario = createWebShellDaemonScenario({
workspaceCwd,
@@ -39,12 +40,18 @@ async function openFiles(
turnCompleteEvent('files-ready', { id: 3 }),
],
});
if (creationEnabled || deletionEnabled || uploadEnabled)
if (
creationEnabled ||
deletionEnabled ||
uploadEnabled ||
fileDeletionEnabled
)
scenario.capabilities.features.push(
...(creationEnabled
? ['workspace_file_write', 'workspace_directory_create']
: []),
...(deletionEnabled ? ['workspace_directory_delete'] : []),
...(fileDeletionEnabled ? ['workspace_file_delete'] : []),
...(uploadEnabled ? ['workspace_file_upload'] : []),
'workspace_qualified_rest_core',
);
@@ -2407,3 +2414,250 @@ test('@smoke coordinates new-folder uploads with folder deletion', async ({
);
await expect(browser.getByRole('progressbar')).toBeHidden();
});
async function openDeleteMenu(
page: Page,
browser: import('@playwright/test').Locator,
name = 'README.md',
) {
await browser
.getByRole('treeitem', { name, exact: true })
.click({ button: 'right' });
await expect(
page.getByRole('menuitem', { name: 'Delete', exact: true }),
).toBeVisible();
}
test('@smoke confirms irreversible file deletion and preserves other tree state', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
false,
true,
true,
true,
true,
);
await browser
.getByRole('treeitem', { name: 'README.md', exact: true })
.click();
await expect(browser.getByTestId('workspace-file-content')).toContainText(
'Workspace guide',
);
await browser.getByRole('treeitem', { name: 'deep', exact: true }).click();
await expect(
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
).toBeVisible();
const calls: { url: string; path: string }[] = [];
let finish!: () => void;
await page.route('**/file/delete', async (route) => {
calls.push({
url: new URL(route.request().url()).pathname,
path: route.request().postDataJSON().path,
});
await new Promise<void>((resolve) => {
finish = resolve;
});
await route.fulfill({ json: { kind: 'file_delete', path: 'README.md' } });
});
await browser.screenshot({
path: test.info().outputPath('delete-before.png'),
});
await openDeleteMenu(page, browser);
await page
.getByRole('menu')
.screenshot({ path: test.info().outputPath('delete-menu.png') });
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
const dialog = page.getByRole('alertdialog', { name: 'Delete file?' });
await expect(dialog).toContainText('/tmp/qwen-web-shell-e2e/README.md');
await expect(dialog).toContainText('This cannot be undone.');
await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeFocused();
expect(calls).toHaveLength(0);
await dialog.screenshot({
path: test.info().outputPath('delete-confirmation.png'),
});
await dialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(dialog).toBeHidden();
await expect(
browser.locator('[role="treeitem"][tabindex="0"]'),
).toBeFocused();
expect(calls).toHaveLength(0);
await expect(
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
).toBeVisible();
await openDeleteMenu(page, browser);
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(
browser.locator('[role="treeitem"][tabindex="0"]'),
).toBeFocused();
expect(calls).toHaveLength(0);
await openDeleteMenu(page, browser);
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await dialog
.getByRole('button', { name: 'Delete file', exact: true })
.click();
await expect.poll(() => calls.length).toBe(1);
expect(calls[0]).toEqual({
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/file/delete',
path: 'README.md',
});
await expect(
dialog.getByRole('button', { name: /^cancel$/i }),
).toBeDisabled();
await expect(
dialog.getByRole('button', { name: 'Deleting…', exact: true }),
).toBeDisabled();
await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();
finish();
await expect(dialog).toBeHidden();
await expect(
browser.locator('[role="treeitem"][tabindex="0"]'),
).toBeFocused();
await expect(
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
).toBeHidden();
await expect(browser.getByTestId('workspace-file-content')).toBeHidden();
await expect(
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
).toBeVisible();
await browser.screenshot({
path: test.info().outputPath('delete-completed.png'),
});
await page.route('**/file/upload?*', (route) =>
route.fulfill({
json: {
kind: 'file_upload',
path: 'README.md',
sizeBytes: 4,
hash: 'sha256:test',
},
}),
);
await dropLocalFiles(page, browser.getByTestId('workspace-file-sidebar'), [
'README.md',
]);
await expect(
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
).toBeVisible();
await expect(browser.getByRole('progressbar')).toBeHidden();
await page.route('**/file/delete', (route) =>
route.fulfill({ json: { kind: 'file_delete', path: 'README.md' } }),
);
await openDeleteMenu(page, browser);
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
await dialog
.getByRole('button', { name: 'Delete file', exact: true })
.click();
await expect(dialog).toBeHidden();
await expect(
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
).toBeHidden();
await expect(
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
).toBeVisible();
});
test('@smoke keeps failed deletions visible for retry', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
false,
false,
true,
true,
);
let calls = 0;
await page.route('**/file/delete', async (route) => {
calls++;
await route.fulfill(
calls === 1
? { status: 403, json: { error: 'Deletion denied' } }
: { json: { kind: 'file_delete', path: 'README.md' } },
);
});
await openDeleteMenu(page, browser);
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
const dialog = page.getByRole('alertdialog');
await dialog
.getByRole('button', { name: 'Delete file', exact: true })
.click();
await expect(dialog.getByRole('alert')).toContainText('Deletion denied');
await expect(
browser.locator('[role="treeitem"][data-item-path="README.md"]'),
).toBeVisible();
await dialog
.getByRole('button', { name: 'Delete file', exact: true })
.click();
await expect(dialog).toBeHidden();
expect(calls).toBe(2);
await expect(
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
).toBeHidden();
});
test('@smoke hides file deletion without capability and for symlinks', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(page, baseURL!);
await browser
.getByRole('treeitem', { name: 'README.md', exact: true })
.click({ button: 'right' });
await expect(
page.getByRole('menuitem', { name: 'Download', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Delete', exact: true }),
).toHaveCount(0);
await page.keyboard.press('Escape');
await browser
.getByRole('treeitem', { name: 'linked', exact: true })
.click({ button: 'right' });
await expect(
page.getByRole('menuitem', { name: 'Download', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Delete', exact: true }),
).toHaveCount(0);
});
test('@smoke never offers deletion for a symlink even with capability', async ({
page,
baseURL,
}) => {
const { browser } = await openFiles(
page,
baseURL!,
undefined,
undefined,
true,
false,
false,
true,
true,
);
await browser
.getByRole('treeitem', { name: 'linked', exact: true })
.click({ button: 'right' });
await expect(
page.getByRole('menuitem', { name: 'Download', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Delete', exact: true }),
).toHaveCount(0);
});