mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
24 files changed
+1160
-44
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user