diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index 3818e862ef..3c3a6f8662 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -89,4 +89,4 @@ Historical failures from 2026-10-14 (not requalified by this audit): | Appearance recovery | no | `scripts/e2e-appearance-recovery.mjs`, `scripts/e2e-message-history-mask.mjs` | [appearance-recovery.md](appearance-recovery.md) | | Three-action tool approval and coherent Full Access | yes | `scripts/e2e-tool-approval-simple.mjs` | [tool-approval-simple.md](tool-approval-simple.md) | -| Workspace request read/write approval and original-task continuation | yes | `scripts/e2e-workspace-request-write.mjs` | [workspace-request-write.md](workspace-request-write.md) | +| Workspace request read/write approval and original-task continuation | yes | `scripts/e2e-workspace-request-write.mjs`, `scripts/e2e-mobile-folder-browse-web.mjs` | [workspace-request-write.md](workspace-request-write.md) | diff --git a/.agents/skills/verify-deepseekbot/features/workspace-request-write.md b/.agents/skills/verify-deepseekbot/features/workspace-request-write.md index fe78a27e39..e16f19a0ce 100644 --- a/.agents/skills/verify-deepseekbot/features/workspace-request-write.md +++ b/.agents/skills/verify-deepseekbot/features/workspace-request-write.md @@ -28,6 +28,10 @@ node scripts/e2e-workspace-request-write.mjs --name ax2080-write --legacy The runner saves screenshots, ARIA, console, Client diagnostics, canonical request/Grant state, file bytes and traces under its named verification directory. It prints only the result and evidence location. A failed attempt stays intact. `--resume-report ` resumes a pending synthetic request after a browser-control fix. When the report already contains a committed Grant, it skips approval and only verifies existing files, trace and duplicate-decision readback; it never replays native execution. `--expect-read-only --instance ` records the original failure on a separately launched base checkout. +### Phone: choose the folder from the Host + +`node scripts/e2e-mobile-folder-browse-web.mjs` drives the same request card in the Expo Web export of the phone app (behavior `desktop.grants.request-write-continue`). A real model asks for a folder, the phone proxy answers DSH `directoryPicker/list` with `directory-picker/unavailable` as a Windows/macOS system-chooser Host does, and the sheet must still list Host folders through `botharness/folderBrowse`: subfolders only, no files, no hidden folders, plus Home and `/` (drives on Windows) under **Other places**. Choosing **Authorize this folder** then **Allow read/write** must leave a writable Grant on exactly that path. The CLI twin is `deepseekbot folder-browse []`. This is layout and flow evidence, not native-device evidence. + ## Gotchas - The current desktop uses `.bh-workspace-rail`; older `.bh-mode-sidebar` journeys time out despite a rendered Bot mode. Read the real ARIA/console before attributing that to permissions. @@ -36,5 +40,5 @@ The runner saves screenshots, ARIA, console, Client diagnostics, canonical reque - A stale machine-local AX model selection can be absent from the pinned catalog. Keep that failed launch; select a supported model for only this launch with `BOTHARNESS_AX_MODEL`, then prove a real reply. - Do not grant the Host Profile directory or its ancestors. This journey uses an isolated synthetic temporary folder and does not resolve broad-Grant exposure of operational data. - A Grant-linked message alone is insufficient. The native files and current durable write permission must agree. -- This journey does not qualify mobile, every native Provider, arbitrary Shell isolation, same-Orchestrator non-blocking waits or the full restart recovery matrix. Those remain separate slices. +- Apart from the phone folder-browse slice above, this journey does not qualify mobile, every native Provider, arbitrary Shell isolation, same-Orchestrator non-blocking waits or the full restart recovery matrix. Those remain separate slices. - After a failed drive run `verify-app observe`, retain its first screenshot/ARIA/console and inspect the [observation guide](../references/observation.md). diff --git a/.github/pr-screenshots/mobile-folder-browse/1-browse-home-dark.png b/.github/pr-screenshots/mobile-folder-browse/1-browse-home-dark.png new file mode 100644 index 0000000000..b88268abf7 Binary files /dev/null and b/.github/pr-screenshots/mobile-folder-browse/1-browse-home-dark.png differ diff --git a/.github/pr-screenshots/mobile-folder-browse/1-browse-home-light.png b/.github/pr-screenshots/mobile-folder-browse/1-browse-home-light.png new file mode 100644 index 0000000000..8e88665573 Binary files /dev/null and b/.github/pr-screenshots/mobile-folder-browse/1-browse-home-light.png differ diff --git a/.github/pr-screenshots/mobile-folder-browse/2-browse-folder-dark.png b/.github/pr-screenshots/mobile-folder-browse/2-browse-folder-dark.png new file mode 100644 index 0000000000..c96c483d08 Binary files /dev/null and b/.github/pr-screenshots/mobile-folder-browse/2-browse-folder-dark.png differ diff --git a/.github/pr-screenshots/mobile-folder-browse/2-browse-folder-light.png b/.github/pr-screenshots/mobile-folder-browse/2-browse-folder-light.png new file mode 100644 index 0000000000..54fab7c7a5 Binary files /dev/null and b/.github/pr-screenshots/mobile-folder-browse/2-browse-folder-light.png differ diff --git a/.github/pr-screenshots/mobile-folder-browse/3-confirm-dark.png b/.github/pr-screenshots/mobile-folder-browse/3-confirm-dark.png new file mode 100644 index 0000000000..1f18a2d9f6 Binary files /dev/null and b/.github/pr-screenshots/mobile-folder-browse/3-confirm-dark.png differ diff --git a/.github/pr-screenshots/mobile-folder-browse/3-confirm-light.png b/.github/pr-screenshots/mobile-folder-browse/3-confirm-light.png new file mode 100644 index 0000000000..155eee058c Binary files /dev/null and b/.github/pr-screenshots/mobile-folder-browse/3-confirm-light.png differ diff --git a/apps/mobile/src/host/bot-settings.ts b/apps/mobile/src/host/bot-settings.ts index a733230de9..e9e917e259 100644 --- a/apps/mobile/src/host/bot-settings.ts +++ b/apps/mobile/src/host/bot-settings.ts @@ -82,6 +82,7 @@ export interface HostFolderListing { crumbs: HostFolder[]; entries: HostFolder[]; truncated: boolean; + roots: HostFolder[]; } export type AccessMode = 'workspace-write' | 'danger-full-access'; @@ -370,20 +371,42 @@ function folder(value: unknown): HostFolder | undefined { return { name: row['name'], path: row['path'], hidden: row['hidden'] === true }; } +function listing(value: unknown): HostFolderListing { + const row = record(value); + if (!text(row?.['path'])) throw new HostAnswerInvalid('The folder list has no location.'); + return { + path: row['path'], + crumbs: some(row['crumbs'], folder), + entries: some(row['entries'], folder), + truncated: row['truncated'] === true, + roots: some(row['roots'], folder), + }; +} + +const systemPickerHosts = new WeakSet(); + export async function listHostFolders( client: HostClient, path?: string, ): Promise { - const value = record( - await client.call('directoryPicker/list', path === undefined ? {} : { path }), - ); - if (!text(value?.['path'])) throw new HostAnswerInvalid('The folder list has no location.'); - return { - path: value['path'], - crumbs: some(value['crumbs'], folder), - entries: some(value['entries'], folder), - truncated: value['truncated'] === true, - }; + const args = path === undefined ? {} : { path }; + if (!systemPickerHosts.has(client)) { + try { + return listing(await client.call('directoryPicker/list', args)); + } catch (error) { + if (!folderBrowsingUnavailable(error)) throw error; + try { + const value = listing(await client.call('folderBrowse', args)); + systemPickerHosts.add(client); + return value; + } catch (fallback) { + if (fallback instanceof MethodRefused && fallback.code.startsWith('folder-browse/')) + throw fallback; + throw error; + } + } + } + return listing(await client.call('folderBrowse', args)); } function option(value: unknown): WorkspaceOption | undefined { diff --git a/apps/mobile/src/ui/chat/folder-picker-sheet.tsx b/apps/mobile/src/ui/chat/folder-picker-sheet.tsx index 2c0a584046..67bb42b3c0 100644 --- a/apps/mobile/src/ui/chat/folder-picker-sheet.tsx +++ b/apps/mobile/src/ui/chat/folder-picker-sheet.tsx @@ -26,7 +26,7 @@ import { SheetNote, SHEET_GROUP, } from './sheet'; -import { Crumbs, Grouped, RowIcon } from './workspace-sheet'; +import { Crumbs, Grouped, locationSection, RowIcon } from './workspace-sheet'; export interface FolderPickerPort { listFolders(path?: string): Promise; @@ -180,6 +180,7 @@ export function FolderPickerSheet({ footer: listing.status === 'ready' && listing.value.truncated ? t.browseTruncated : undefined, data: folderRows, }, + ...(listing.status === 'ready' ? locationSection(listing.value) : []), ]; return ( ({ + kind: 'folder' as const, + key: `root:${root.path}`, + folder: index === 0 ? { ...root, name: t.browseHome } : root, + })) + .filter((row) => row.folder.path !== listing.path); + return data.length === 0 ? [] : [{ key: 'locations', title: t.browseLocations, data }]; +} + export function templateTitle(template: Pick): string { if (template.name === 'cautious') return t.autoApprovalCautious; if (template.name === 'get-work-done') return t.autoApprovalGetWorkDone; @@ -448,6 +459,7 @@ export function WorkspaceSheet({ listing.status === 'ready' && listing.value.truncated ? t.browseTruncated : undefined, data: folderRows, }, + ...(listing.status === 'ready' ? locationSection(listing.value) : []), ]; return ( { expect(folderBrowsingUnavailable(new Error('other'))).toBe(false); }); + it('lists folders through BotHarness once DSH uses the system chooser', async () => { + const fake = new FakeHost(); + fake.answers.set('directoryPicker/list', () => ({ + ok: false, + error: { code: 'directory-picker/unavailable', message: 'No folder browser' }, + })); + fake.methods.set('folderBrowse', (args) => { + if (args['path'] === 'D:\\missing') throw new Error('gone'); + return { + path: args['path'] ?? 'C:\\Users\\ada', + crumbs: [{ name: 'C:\\', path: 'C:\\' }], + entries: [{ name: 'code', path: 'C:\\Users\\ada\\code' }], + truncated: false, + roots: [ + { name: 'ada', path: 'C:\\Users\\ada' }, + { name: 'D:\\', path: 'D:\\' }, + ], + }; + }); + const host = client(fake); + + const home = await listHostFolders(host); + const drive = await listHostFolders(host, 'D:\\'); + + expect(home.entries.map((entry) => entry.name)).toEqual(['code']); + expect(home.roots.map((entry) => entry.path)).toEqual(['C:\\Users\\ada', 'D:\\']); + expect(drive.path).toBe('D:\\'); + expect(calls(fake)).toEqual([ + ['folderBrowse', {}], + ['folderBrowse', { path: 'D:\\' }], + ]); + expect(fake.requests).toContain(`POST ${ROUTE}/botharness-device/api/botharness/folderBrowse`); + expect( + await listHostFolders(host, 'D:\\missing').catch((error: unknown) => error), + ).toMatchObject({ + code: 'invalid-input', + }); + }); + + it('keeps the system chooser refusal when the computer is too old to list folders', async () => { + const fake = new FakeHost(); + fake.answers.set('directoryPicker/list', () => ({ + ok: false, + error: { code: 'directory-picker/unavailable', message: 'No folder browser' }, + })); + fake.answers.set('folderBrowse', () => ({ + ok: false, + error: { code: 'unknown-method', message: 'folderBrowse is not a method' }, + })); + + const failure = await listHostFolders(client(fake)).catch((error: unknown) => error); + + expect(folderBrowsingUnavailable(failure)).toBe(true); + }); + it('resolves a folder before the confirmation, then grants it', async () => { const fake = new FakeHost(); fake.methods.set('workspace/create', (args) => { diff --git a/apps/mobile/test/grant-request-card.test.ts b/apps/mobile/test/grant-request-card.test.ts index b24082849e..b6958415f6 100644 --- a/apps/mobile/test/grant-request-card.test.ts +++ b/apps/mobile/test/grant-request-card.test.ts @@ -173,13 +173,48 @@ describe('Folder request card', () => { expect(approvals(fake)).toEqual([]); }); - it('takes a typed folder path when the computer opens its own folder chooser', async () => { + it('browses through BotHarness when the computer opens its own folder chooser', async () => { const fake = host(); + const listed = fake.methods.get('directoryPicker/list')!; fake.methods.delete('directoryPicker/list'); fake.answers.set('directoryPicker/list', () => ({ ok: false, error: { code: 'directory-picker/unavailable', message: 'native chooser' }, })); + fake.methods.set('folderBrowse', (args) => ({ + ...(listed(args) as object), + roots: [ + { name: 'qa', path: '/Users/qa' }, + { name: 'D:\\', path: 'D:\\' }, + ], + })); + const view = await render(card(connect(fake))); + await press(button(view, t.grantChoose)); + expect(view.textContent).not.toContain(t.browseUnavailableTypePath); + expect(view.textContent).toContain(t.browseLocations); + expect(view.querySelector(`[aria-label="${t.folderPath}"]`)).toBeNull(); + await press(button(view, 'Project')); + await press(button(view, t.authorizeHere)); + expect(fake.calls.filter((call) => call.method === 'workspace/create')).toEqual([ + { method: 'workspace/create', args: { request: { path: PROJECT.path } } }, + ]); + expect(fake.calls.filter((call) => call.method === 'folderBrowse')).toEqual([ + { method: 'folderBrowse', args: {} }, + { method: 'folderBrowse', args: { path: PROJECT.path } }, + ]); + }); + + it('takes a typed folder path when an older computer cannot list folders', async () => { + const fake = host(); + fake.methods.delete('directoryPicker/list'); + fake.answers.set('directoryPicker/list', () => ({ + ok: false, + error: { code: 'directory-picker/unavailable', message: 'native chooser' }, + })); + fake.answers.set('folderBrowse', () => ({ + ok: false, + error: { code: 'unknown-method', message: 'folderBrowse is not a method' }, + })); const view = await render(card(connect(fake))); await press(button(view, t.grantChoose)); expect(view.textContent).toContain(t.browseUnavailableTypePath); diff --git a/changes/2392-phone-folder-browse.md b/changes/2392-phone-folder-browse.md new file mode 100644 index 0000000000..1b4584f832 --- /dev/null +++ b/changes/2392-phone-folder-browse.md @@ -0,0 +1,11 @@ +--- +section: Added +--- + +## en + +- On a computer that opens its own folder dialog, the phone can now browse the computer's folders to grant one to a Bot, instead of typing a path. Folder names only are listed; `deepseekbot folder-browse` lists the same folders ([#2392](https://github.com/BotHarness/DeepSeekBot/pull/2392)). + +## zh + +- 电脑使用系统自带的文件夹选择器时,手机现在也能浏览电脑上的文件夹,选一个授权给 Bot,不必再手动输入路径。只列出文件夹名称;`deepseekbot folder-browse` 可列出同样的文件夹([#2392](https://github.com/BotHarness/DeepSeekBot/pull/2392))。 diff --git a/docs/bot-cli.md b/docs/bot-cli.md index fc754d9ca9..1800822b56 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -108,6 +108,7 @@ deepseekbot ask-secret-revoke --message-id deepseekbot ask-secret-accounts deepseekbot release-info [--since ] deepseekbot workspace-options +deepseekbot folder-browse [] deepseekbot grant-create --workspace ``` @@ -721,6 +722,15 @@ deepseekbot grants `grant-request-approve` uses the same Owner-only Host command as the Web request card. The Host commits persistent read/write authority and the Grant-linked resolution together before continuing the original task through its existing Inbox. It also upgrades an existing read-only Grant after this explicit confirmation. Repeating the same approved request reads back the existing decision; it does not create another Grant or wake. A revoked or changed target is refused. An old tool approval is not a Workspace request: use this command rather than `tool-approval-decide`. Missing `--confirm` is `usage`; unavailable or stale authority returns a coded refusal. This is an online command, not an offline database repair. +### Browse Host folders + +```bash +deepseekbot folder-browse +deepseekbot folder-browse 'D:\code' +``` + +`folder-browse` lists the subfolders of one Host folder, the same way the phone folder picker does when the computer uses the system folder dialog. With no path it starts at the Host home. It returns `{path, home, crumbs, entries, truncated, roots}`: folder names and paths only, never files. `roots` holds the home folder and the drives (Windows) or `/`. A relative path fails `folder-browse/invalid-path`; a missing, non-folder or unreadable path fails `folder-browse/not-found`, `folder-browse/not-directory` or `folder-browse/denied`. Browsing grants nothing: register the chosen path and grant it as above. + ## Memory `memory-snapshot`, `memory-file --path`, `memory-history [--limit]`, and `memory-diff --sha` read the Bot memory store; `memory-save --path (--body | --body-stdin)` writes one file and commits it. `--expected-head` defaults to the current HEAD (pass it explicitly for compare-and-swap); `--edit-id` defaults to a fresh UUID and makes repeat submissions idempotent. A stale head or a no-change write fails with `memory-conflict`, a missing file reads back `null`, and malformed shas fail `invalid-input`. diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 1f520648b1..674ae2a161 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -103,6 +103,7 @@ deepseekbot user-question-status --message-id <卡片-id> deepseekbot user-question-answer --message-id <卡片-id> --answer-stdin deepseekbot release-info [--since <版本>] deepseekbot workspace-options +deepseekbot folder-browse [<文件夹路径>] deepseekbot grant-create --workspace ``` @@ -707,6 +708,15 @@ deepseekbot grants `grant-request-approve` 与 Web 请求卡使用同一个仅 Owner 可调用的 Host 命令。Host 在同一事务中保存持续读写权限和关联 Grant 的决议,再沿既有 Inbox 继续原任务;已有只读 Grant 也可经这次明确确认升级。重复批准同一请求只读回既有决定,不重复创建 Grant 或唤醒;已撤销或改变的目标会被拒绝。目录卡不是工具批准,应使用此命令,而非 `tool-approval-decide`。缺少 `--confirm` 返回 `usage`,失效或不可用权限返回稳定拒绝代码。这是在线操作,不是直接修改数据库的离线修复。 +### 浏览电脑上的文件夹 + +```bash +deepseekbot folder-browse +deepseekbot folder-browse 'D:\code' +``` + +`folder-browse` 列出 Host 上某个文件夹的子文件夹,与电脑使用系统文件夹选择器时手机上的文件夹选择相同。不传路径时从 Host 主目录开始。返回 `{path, home, crumbs, entries, truncated, roots}`:只有文件夹名称和路径,不含文件。`roots` 是主目录和各个磁盘(Windows)或 `/`。相对路径返回 `folder-browse/invalid-path`;不存在、不是文件夹或无权读取分别返回 `folder-browse/not-found`、`folder-browse/not-directory`、`folder-browse/denied`。浏览不授予任何权限:选好路径后按上文登记并授权。 + ## Memory `memory-snapshot`、`memory-file --path`、`memory-history [--limit]`、`memory-diff --sha` 读取 Bot 记忆库;`memory-save --path (--body | --body-stdin)` 写一个文件并提交。`--expected-head` 默认取当前 HEAD(要做 compare-and-swap 请显式传);`--edit-id` 默认生成新的 UUID,重复提交幂等。HEAD 过期或无变化的写入报 `memory-conflict`,不存在的文件读回 `null`,非法 sha 报 `invalid-input`。 diff --git a/packages/core/src/bots/cli-live.ts b/packages/core/src/bots/cli-live.ts index 1c12a56e10..8eb8868dbb 100644 --- a/packages/core/src/bots/cli-live.ts +++ b/packages/core/src/bots/cli-live.ts @@ -259,6 +259,10 @@ export const LIVE_COMMANDS = [ }, { command: 'release-info', description: 'read the running Host release lifecycle' }, { command: 'workspace-options', description: 'list live Host workspaces for grants' }, + { + command: 'folder-browse', + description: 'list folder names on the Host computer, as the phone folder picker does', + }, { command: 'grant-create', description: 'authorize a Bot workspace through the live Host' }, { command: 'browser-access-set', @@ -816,6 +820,12 @@ export async function runLiveCli( } if (DEVICE_COMMANDS.some((entry) => entry === command)) return runDeviceCommand(command as (typeof DEVICE_COMMANDS)[number], rest, values, io); + if (command === 'folder-browse') { + if (rest.length > 1) usage('folder-browse takes at most one folder path.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const path = rest[0]?.trim(); + return (await connectLiveHost(values, io, signal)).rpc('folderBrowse', path ? { path } : {}); + } if (command === 'grants') { if (rest.length !== 1 || !rest[0]?.trim()) usage('grants needs exactly one bot id.'); const signal = AbortSignal.timeout(timeoutMs(values.timeout)); diff --git a/packages/core/src/bridge/methods.ts b/packages/core/src/bridge/methods.ts index 9e5b0b3f71..9d695813e8 100644 --- a/packages/core/src/bridge/methods.ts +++ b/packages/core/src/bridge/methods.ts @@ -260,6 +260,11 @@ import { type WorkspaceGrant, type WorkspaceGrantStore, } from '../workspaces/grants.js'; +import { + FolderBrowseError, + type FolderBrowser, + type FolderListing, +} from '../workspaces/folder-browse.js'; import type { ChannelToolApproval } from '../workspaces/tool-approval.js'; import type { ChannelUserQuestions } from '../channels/user-questions.js'; import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions/types'; @@ -659,6 +664,7 @@ export interface BridgeMethods { workspaceFileTarget( payload: unknown, ): BridgeResult<{ target: { path: string; relativePath: ''; kind: 'directory' } }>; + folderBrowse(payload: unknown): BridgeResult; grants(payload: unknown): BridgeResult<{ grants: WorkspaceGrant[] }>; grantCreate( payload: unknown, @@ -880,6 +886,7 @@ export interface BridgeMethodsDeps { humanAttention?: HumanAttentionQuery; humanAttentionDecisions?: HumanAttentionDecisions; grants?: WorkspaceGrantStore; + folders?: FolderBrowser; externalMessaging?: OutboundMessaging; toolApproval?: ChannelToolApproval; userQuestions?: ChannelUserQuestions; @@ -5289,6 +5296,25 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { throw error; } }, + folderBrowse(payload) { + const input = z + .object({ path: z.string().max(1024).optional() }) + .strict() + .safeParse(payload ?? {}); + if (!input.success) return invalidInput('path must be a folder path'); + if (deps.folders === undefined) + return { + ok: false, + error: { code: 'unavailable', message: 'Folder browsing is unavailable' }, + }; + try { + return { ok: true, value: deps.folders.list(input.data.path) }; + } catch (error) { + if (error instanceof FolderBrowseError) + return { ok: false, error: { code: error.code, message: error.message } }; + throw error; + } + }, workspaceFileTarget(payload) { const source = asObject(payload); const slug = asNonBlank(source, 'slug'); diff --git a/packages/core/src/bridge/rpc.ts b/packages/core/src/bridge/rpc.ts index a3aad6fda6..394bdd9ea2 100644 --- a/packages/core/src/bridge/rpc.ts +++ b/packages/core/src/bridge/rpc.ts @@ -137,6 +137,7 @@ import type { import type { GitStatus } from '../memory/managed-git.js'; import type { MemoryRecoveryCheckpoint } from '../memory/recovery.js'; import type { WorkspaceGrant } from '../workspaces/grants.js'; +import type { FolderListing } from '../workspaces/folder-browse.js'; import type { ToolApprovalRule } from '../workspaces/tool-approval-rules.js'; import type { AssignmentAccessPreset, @@ -1379,6 +1380,10 @@ export class BotharnessBridgeService extends TypertRemoteService { return unwrap(this.methods.messageAttachmentTarget({ channelId, messageId, fileId })); } + folderBrowse(path?: string): FolderListing { + return unwrap(this.methods.folderBrowse(path === undefined ? {} : { path })); + } + workspaceFileTarget( slug: string, grantId: string, @@ -1955,6 +1960,7 @@ markRemoteMethods(BotharnessBridgeService.prototype, [ 'messageAttachmentTarget', 'channelMediaTarget', 'workspaceFileTarget', + 'folderBrowse', 'grants', 'grantCreate', 'grantRevoke', diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 40f322766c..29aec0ab91 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,4 +1,5 @@ import { createWindowZoomControl, WINDOW_ZOOM_CONTROL_PATH } from './window-zoom/control.js'; +import { createFolderBrowser } from './workspaces/folder-browse.js'; import { APPEARANCE_CONTROL_PATH, createAppearanceControl } from './appearance/control.js'; import { CloudConnections } from './cloud/connections.js'; import { createCloudProvider } from './cloud/provider.js'; @@ -2209,6 +2210,7 @@ export function apply(ctx: Context, config: BotHarnessConfig): void { humanAttention: core.humanAttention, humanAttentionDecisions: core.humanAttentionDecisions, grants: core.grants, + folders: createFolderBrowser(), externalMessaging: core.externalMessaging, toolApproval, userQuestions, diff --git a/packages/core/src/workspaces/folder-browse.ts b/packages/core/src/workspaces/folder-browse.ts new file mode 100644 index 0000000000..f02eda54a8 --- /dev/null +++ b/packages/core/src/workspaces/folder-browse.ts @@ -0,0 +1,153 @@ +import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs'; +import { homedir } from 'node:os'; +import nodePath from 'node:path'; + +export const FOLDER_BROWSE_LIMIT = 500; +const PATH_LIMIT = 1024; + +export interface BrowsedFolder { + name: string; + path: string; + hidden: boolean; +} + +export interface FolderListing { + path: string; + home: string; + crumbs: BrowsedFolder[]; + entries: BrowsedFolder[]; + truncated: boolean; + roots: BrowsedFolder[]; +} + +export type FolderBrowseRefusal = + | 'folder-browse/invalid-path' + | 'folder-browse/not-found' + | 'folder-browse/not-directory' + | 'folder-browse/denied'; + +export class FolderBrowseError extends Error { + constructor( + readonly code: FolderBrowseRefusal, + message: string, + ) { + super(message); + this.name = 'FolderBrowseError'; + } +} + +export interface FolderBrowserDeps { + platform?: NodeJS.Platform; + home?: string; + readdir?: (path: string) => Dirent[]; + isDirectory?: (path: string) => boolean; + exists?: (path: string) => boolean; +} + +export interface FolderBrowser { + list(path?: string): FolderListing; +} + +function errnoCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code: unknown }).code) + : undefined; +} + +export function createFolderBrowser(deps: FolderBrowserDeps = {}): FolderBrowser { + const platform = deps.platform ?? process.platform; + const path = platform === 'win32' ? nodePath.win32 : nodePath.posix; + const home = deps.home ?? homedir(); + const readdir = deps.readdir ?? ((dir: string) => readdirSync(dir, { withFileTypes: true })); + const isDirectory = + deps.isDirectory ?? + ((target: string) => { + try { + return statSync(target).isDirectory(); + } catch { + return false; + } + }); + const exists = deps.exists ?? existsSync; + + const folder = (target: string, name?: string): BrowsedFolder => { + const label = name ?? (path.basename(target) || target); + return { name: label, path: target, hidden: label.startsWith('.') && label !== target }; + }; + + const resolve = (input: string | undefined): string => { + if (input === undefined || input.trim() === '' || input.trim() === '~') return home; + const raw = input.trim(); + if (raw.length > PATH_LIMIT || raw.includes('\0')) + throw new FolderBrowseError('folder-browse/invalid-path', 'The folder path is not valid.'); + const expanded = /^~[\\/]/.test(raw) ? path.join(home, raw.slice(2)) : raw; + if (!path.isAbsolute(expanded) || (platform === 'win32' && !/^[a-z]:/i.test(expanded))) + throw new FolderBrowseError( + 'folder-browse/invalid-path', + 'Use a full folder path on the computer.', + ); + const resolved = path.resolve(expanded); + const root = path.parse(resolved).root; + return resolved.length > root.length ? resolved.replace(/[\\/]+$/, '') : root; + }; + + const crumbsOf = (target: string): BrowsedFolder[] => { + const crumbs: BrowsedFolder[] = []; + for (let at = target; ; at = path.dirname(at)) { + crumbs.unshift(folder(at)); + if (path.dirname(at) === at) return crumbs; + } + }; + + const roots = (): BrowsedFolder[] => { + const places = [folder(home)]; + if (platform !== 'win32') return [...places, folder('/')]; + for (let code = 'A'.charCodeAt(0); code <= 'Z'.charCodeAt(0); code += 1) { + const drive = `${String.fromCharCode(code)}:\\`; + if (exists(drive)) places.push(folder(drive)); + } + return places; + }; + + return { + list(input) { + const target = resolve(input); + let dirents: Dirent[]; + try { + dirents = readdir(target); + } catch (error) { + const code = errnoCode(error); + if (code === 'ENOENT') + throw new FolderBrowseError('folder-browse/not-found', 'That folder does not exist.'); + if (code === 'ENOTDIR') + throw new FolderBrowseError('folder-browse/not-directory', 'That path is not a folder.'); + if (code === 'EACCES' || code === 'EPERM') + throw new FolderBrowseError( + 'folder-browse/denied', + 'The computer does not allow reading that folder.', + ); + throw error; + } + const folders = dirents + .filter( + (entry) => + entry.isDirectory() || + (entry.isSymbolicLink() && isDirectory(path.join(target, entry.name))), + ) + .map((entry) => folder(path.join(target, entry.name), entry.name)) + .toSorted( + (left, right) => + Number(left.hidden) - Number(right.hidden) || + left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }), + ); + return { + path: target, + home, + crumbs: crumbsOf(target), + entries: folders.slice(0, FOLDER_BROWSE_LIMIT), + truncated: folders.length > FOLDER_BROWSE_LIMIT, + roots: roots(), + }; + }, + }; +} diff --git a/packages/core/test/bot-live-cli.test.ts b/packages/core/test/bot-live-cli.test.ts index 20484ef8ce..825ac4d3de 100644 --- a/packages/core/test/bot-live-cli.test.ts +++ b/packages/core/test/bot-live-cli.test.ts @@ -243,6 +243,22 @@ describe('live CLI over the authenticated DSH carrier', () => { expect(live.calls.at(-1)?.args).toEqual({ channelId: 'dm-ada', messageId: 'question', answer }); }); + it('browses Host folders through the same method as the phone picker', async () => { + const live = await host((_method, args) => ({ + ok: true, + value: { path: args['path'] ?? '/Users/ada', entries: [{ name: 'site' }] }, + })); + const home = await invoke(['folder-browse'], live.url); + const code = await invoke(['folder-browse', 'D:\\code'], live.url); + expect(home.json.path).toBe('/Users/ada'); + expect(code.json.path).toBe('D:\\code'); + expect(live.calls).toEqual([ + { method: 'folderBrowse', args: {} }, + { method: 'folderBrowse', args: { path: 'D:\\code' } }, + ]); + expect((await invoke(['folder-browse', '/a', '/b'], live.url)).json.error.code).toBe('usage'); + }); + it('rejects missing/unsafe origins, malformed answers and expired auth before mutation', async () => { expect((await invoke(['send', 'ada', '--body', 'hi'])).json.error.code).toBe('usage'); expect((await invoke(['release-info'], 'http://example.com')).json.error.code).toBe('usage'); diff --git a/packages/core/test/bridge-rpc.test.ts b/packages/core/test/bridge-rpc.test.ts index 2b64f33296..2107b75d4e 100644 --- a/packages/core/test/bridge-rpc.test.ts +++ b/packages/core/test/bridge-rpc.test.ts @@ -624,6 +624,7 @@ describe('bridge typert service', () => { 'messageAttachmentTarget', 'channelMediaTarget', 'workspaceFileTarget', + 'folderBrowse', 'grants', 'grantCreate', 'grantRevoke', diff --git a/packages/core/test/folder-browse.test.ts b/packages/core/test/folder-browse.test.ts new file mode 100644 index 0000000000..144c10dfe3 --- /dev/null +++ b/packages/core/test/folder-browse.test.ts @@ -0,0 +1,160 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync, type Dirent } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createFolderBrowser, + FOLDER_BROWSE_LIMIT, + FolderBrowseError, +} from '../src/workspaces/folder-browse.js'; +import { createBridgeMethods } from '../src/bridge/methods.js'; + +const temps: string[] = []; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function home(): string { + const dir = mkdtempSync(join(tmpdir(), 'folder-browse-')); + temps.push(dir); + return dir; +} + +function dirent(name: string, kind: 'dir' | 'file' = 'dir'): Dirent { + return { + name, + isDirectory: () => kind === 'dir', + isSymbolicLink: () => false, + } as Dirent; +} + +function refusal(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + if (error instanceof FolderBrowseError) return error.code; + throw error; + } + return undefined; +} + +describe('folder browsing on the Host', () => { + it('starts at home and lists folder names only, hidden ones last', () => { + const root = home(); + mkdirSync(join(root, 'site')); + mkdirSync(join(root, 'Notes')); + mkdirSync(join(root, '.config')); + writeFileSync(join(root, 'secret.txt'), 'never listed'); + symlinkSync(join(root, 'site'), join(root, 'site-link')); + symlinkSync(join(root, 'secret.txt'), join(root, 'file-link')); + + const listing = createFolderBrowser({ home: root, platform: 'linux' }).list(); + + expect(listing.path).toBe(root); + expect(listing.home).toBe(root); + expect(listing.entries.map((entry) => [entry.name, entry.hidden])).toEqual([ + ['Notes', false], + ['site', false], + ['site-link', false], + ['.config', true], + ]); + expect(listing.entries[0]?.path).toBe(join(root, 'Notes')); + expect(listing.crumbs.at(0)).toEqual({ name: '/', path: '/', hidden: false }); + expect(listing.crumbs.at(-1)?.path).toBe(root); + expect(listing.roots.map((entry) => entry.path)).toEqual([root, '/']); + expect(listing.truncated).toBe(false); + }); + + it('expands ~ and drops a trailing separator', () => { + const root = home(); + mkdirSync(join(root, 'code', 'app'), { recursive: true }); + const browser = createFolderBrowser({ home: root, platform: 'linux' }); + + expect(browser.list('~/code/').path).toBe(join(root, 'code')); + expect(browser.list(`${root}/code/../code`).entries.map((entry) => entry.name)).toEqual([ + 'app', + ]); + }); + + it('refuses relative, missing, file and unreadable paths with codes', () => { + const root = home(); + writeFileSync(join(root, 'file.txt'), ''); + const browser = createFolderBrowser({ home: root, platform: 'linux' }); + + expect(refusal(() => browser.list('code'))).toBe('folder-browse/invalid-path'); + expect(refusal(() => browser.list(`${root}/missing`))).toBe('folder-browse/not-found'); + expect(refusal(() => browser.list(`${root}/file.txt`))).toBe('folder-browse/not-directory'); + const denied = createFolderBrowser({ + home: root, + platform: 'linux', + readdir: () => { + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + }, + }); + expect(refusal(() => denied.list('/root'))).toBe('folder-browse/denied'); + }); + + it('walks Windows paths and offers each existing drive', () => { + const seen: string[] = []; + const browser = createFolderBrowser({ + platform: 'win32', + home: 'C:\\Users\\ada', + exists: (drive) => drive === 'C:\\' || drive === 'D:\\', + readdir: (dir) => { + seen.push(dir); + return [dirent('project'), dirent('notes.md', 'file')]; + }, + }); + + const listing = browser.list('D:\\code\\'); + + expect(seen).toEqual(['D:\\code']); + expect(listing.entries).toEqual([ + { name: 'project', path: 'D:\\code\\project', hidden: false }, + ]); + expect(listing.crumbs).toEqual([ + { name: 'D:\\', path: 'D:\\', hidden: false }, + { name: 'code', path: 'D:\\code', hidden: false }, + ]); + expect(listing.roots.map((entry) => entry.path)).toEqual(['C:\\Users\\ada', 'C:\\', 'D:\\']); + expect(browser.list('D:\\').path).toBe('D:\\'); + expect(refusal(() => browser.list('/code'))).toBe('folder-browse/invalid-path'); + }); + + it('caps a very large folder', () => { + const names = Array.from({ length: FOLDER_BROWSE_LIMIT + 5 }, (_, at) => + dirent(`d${String(at).padStart(4, '0')}`), + ); + const listing = createFolderBrowser({ + platform: 'linux', + home: '/home/ada', + readdir: () => names, + }).list(); + + expect(listing.entries).toHaveLength(FOLDER_BROWSE_LIMIT); + expect(listing.truncated).toBe(true); + }); + + it('answers through the bridge with coded refusals', () => { + const root = home(); + mkdirSync(join(root, 'site')); + const methods = createBridgeMethods({ + folders: createFolderBrowser({ home: root, platform: 'linux' }), + } as unknown as Parameters[0]); + + const listed = methods.folderBrowse({}); + expect(listed.ok && listed.value.entries.map((entry) => entry.name)).toEqual(['site']); + expect(methods.folderBrowse({ path: 'relative' })).toEqual({ + ok: false, + error: { + code: 'folder-browse/invalid-path', + message: 'Use a full folder path on the computer.', + }, + }); + expect(methods.folderBrowse({ path: 3 })).toMatchObject({ + ok: false, + error: { code: 'invalid-input' }, + }); + }); +}); diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index edc14e75b5..16e0c13169 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -559,6 +559,7 @@ describe('plugin entry', () => { 'messageAttachmentTarget', 'channelMediaTarget', 'workspaceFileTarget', + 'folderBrowse', 'grants', 'grantCreate', 'grantRevoke', diff --git a/scripts/e2e-mobile-folder-browse-web.mjs b/scripts/e2e-mobile-folder-browse-web.mjs new file mode 100644 index 0000000000..9b8d8981dd --- /dev/null +++ b/scripts/e2e-mobile-folder-browse-web.mjs @@ -0,0 +1,375 @@ +import { spawnSync } from 'node:child_process'; +import { + createReadStream, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { createServer, request } from 'node:http'; +import { createServer as createNetServer, connect } from 'node:net'; +import { homedir, tmpdir } from 'node:os'; +import { basename, extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { verifyPaths } from './verify-app.mjs'; + +const root = resolve(fileURLToPath(import.meta.url), '../..'); +const args = process.argv.slice(2); +const skipExport = args.includes('--skip-export'); +const keep = args.includes('--keep'); +const dist = join(root, 'apps/mobile/dist-web'); +const paths = verifyPaths('mobile-folder-browse-web'); +const home = mkdtempSync(join(tmpdir(), 'bh-e2e-mobile-folders-')); +const fixture = join(homedir(), 'bh-folder-browse-qa'); +const steps = []; +const checks = {}; + +const types = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.ttf': 'font/ttf', + '.svg': 'image/svg+xml', +}; + +const redact = (text) => String(text).replace(/\?token=[^\s"']+/gu, '?token='); + +async function freePort() { + const server = createNetServer(); + await new Promise((done, fail) => { + server.once('error', fail); + server.listen(0, '127.0.0.1', done); + }); + const { port } = server.address(); + await new Promise((done) => server.close(done)); + return port; +} + +function launch(port) { + const result = spawnSync( + process.execPath, + ['scripts/dev-instance.mjs', '--home', home, '--port', String(port), '--json'], + { cwd: root, encoding: 'utf8', timeout: 600_000, maxBuffer: 4 * 1024 * 1024 }, + ); + const start = (result.stdout ?? '').indexOf('{'); + if (result.status !== 0 || start < 0) + throw new Error(`dev-instance.mjs failed: ${redact(result.stderr ?? '').slice(-800)}`); + const instance = JSON.parse(result.stdout.slice(start)); + if (instance.health?.ok !== true) throw new Error('isolated Host is not healthy'); + return instance; +} + +async function stop(pid) { + process.kill(pid, 'SIGTERM'); + for (let i = 0; i < 100; i += 1) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((done) => setTimeout(done, 100)); + } + process.kill(pid, 'SIGKILL'); +} + +function cookie() { + const jar = join(tmpdir(), `dsh-${basename(home).replace(/[^a-zA-Z0-9-]/gu, '-')}.cookies`); + return readFileSync(jar, 'utf8').split(';')[0]; +} + +async function operator(hostOrigin, path, body) { + const response = await fetch(`${hostOrigin}${path}`, { + method: 'POST', + signal: AbortSignal.timeout(30_000), + headers: { 'content-type': 'application/json', cookie: cookie() }, + body: JSON.stringify(body), + }); + const value = await response.json().catch(() => undefined); + if (response.status !== 200) throw new Error(`${path} answered ${response.status}`); + return value; +} + +async function rpc(hostOrigin, method, rpcArgs = {}) { + const envelope = await operator(hostOrigin, `/api/botharness/${method}`, { + type: 'client-request', + rpcId: `mobile-folders-e2e-${Date.now()}`, + method: `botharness/${method}`, + payload: { args: rpcArgs }, + }); + return envelope?.result; +} + +async function call(hostOrigin, method, rpcArgs = {}) { + const result = await rpc(hostOrigin, method, rpcArgs); + if (result?.ok !== true) + throw new Error(`botharness/${method} failed: ${result?.error?.code ?? 'invalid'}`); + return result.value; +} + +function phoneOrigin(state) { + const forward = (req, res) => { + if (req.headers.authorization?.startsWith('Device ')) + state.authorization = req.headers.authorization; + if (state.systemChooser && req.url === '/botharness-device/api/directoryPicker/list') { + state.chooserRefusals += 1; + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const { rpcId } = JSON.parse(body || '{}'); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + type: 'server-response', + rpcId, + result: { + ok: false, + error: { code: 'directory-picker/unavailable', message: 'system folder dialog' }, + }, + }), + ); + }); + return; + } + const upstream = request( + { + host: '127.0.0.1', + port: state.hostPort, + method: req.method, + path: req.url, + headers: req.headers, + }, + (answer) => { + res.writeHead(answer.statusCode ?? 502, answer.headers); + answer.pipe(res); + }, + ); + upstream.on('error', () => { + if (!res.headersSent) res.writeHead(502); + res.end(); + }); + req.pipe(upstream); + }; + const serveStatic = (req, res) => { + const pathname = decodeURIComponent(new URL(req.url, 'http://phone').pathname); + let file = normalize(join(dist, pathname)); + if (!file.startsWith(dist)) return res.writeHead(403).end(); + if (!existsSync(file) || statSync(file).isDirectory()) { + const html = `${file}.html`; + file = existsSync(html) ? html : join(dist, 'index.html'); + } + res.writeHead(200, { 'content-type': types[extname(file)] ?? 'application/octet-stream' }); + createReadStream(file).pipe(res); + }; + const server = createServer((req, res) => + req.url.startsWith('/botharness-device/') ? forward(req, res) : serveStatic(req, res), + ); + server.on('upgrade', (req, socket, head) => { + if (!req.url.startsWith('/botharness-device/')) return socket.destroy(); + const upstream = connect(state.hostPort, '127.0.0.1', () => { + const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]; + lines.push(`${name}: ${req.rawHeaders[i + 1]}`); + } + if (req.headers.authorization === undefined && state.authorization !== undefined) + lines.push(`authorization: ${state.authorization}`); + upstream.write(`${lines.join('\r\n')}\r\n\r\n`); + if (head.length > 0) upstream.write(head); + upstream.pipe(socket); + socket.pipe(upstream); + }); + upstream.on('error', () => socket.destroy()); + socket.on('error', () => upstream.destroy()); + }); + return server; +} + +function loadPuppeteer() { + const store = join(root, 'node_modules', '.pnpm'); + const entry = readdirSync(store).find((name) => name.startsWith('puppeteer@')); + if (entry === undefined) throw new Error('puppeteer is not installed; run pnpm install'); + return createRequire(join(store, entry, 'node_modules', '/'))('puppeteer'); +} + +async function main() { + mkdirSync(paths.evidence, { recursive: true }); + rmSync(fixture, { recursive: true, force: true }); + for (const name of ['site', 'notes', '.cache']) + mkdirSync(join(fixture, name), { recursive: true }); + writeFileSync(join(fixture, 'README.md'), 'files are never listed'); + + if (!skipExport || !existsSync(join(dist, 'index.html'))) { + const exported = spawnSync('pnpm', ['--filter', '@botharness/mobile', 'export:web'], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, EXPO_OFFLINE: '1' }, + timeout: 600_000, + maxBuffer: 16 * 1024 * 1024, + }); + if (exported.status !== 0) throw new Error(`export:web failed: ${exported.stderr.slice(-800)}`); + steps.push('exported the app for web'); + } + + const hostPort = await freePort(); + const instance = launch(hostPort); + const hostOrigin = `http://127.0.0.1:${hostPort}`; + steps.push('launched an isolated Host'); + const native = await operator(hostOrigin, '/api/directoryPicker/list', { + type: 'client-request', + rpcId: 'folders-native', + method: 'directoryPicker/list', + payload: { args: {} }, + }); + steps.push( + `DSH directoryPicker/list on this Host: ${native?.result?.ok === true ? 'lists folders' : native?.result?.error?.code}`, + ); + const state = { hostPort, systemChooser: true, chooserRefusals: 0 }; + const proxy = phoneOrigin(state); + const proxyPort = await freePort(); + await new Promise((done) => proxy.listen(proxyPort, '127.0.0.1', done)); + const origin = `http://127.0.0.1:${proxyPort}`; + + const browser = await loadPuppeteer().launch({ args: ['--no-sandbox', '--lang=en-US'] }); + const console_ = []; + let page; + try { + const displayName = 'Nova'; + const { bot } = await call(hostOrigin, 'create', { displayName, roles: ['research'] }); + const { channel } = await call(hostOrigin, 'channelDm', { slug: bot.slug, displayName }); + await call(hostOrigin, 'channelSend', { + channelId: channel.id, + messageId: `human-${crypto.randomUUID()}`, + body: `QA: call request_workspace_grant once, asking for read/write access to a folder so you can sort today's to-dos. Do not name a path. Then end this turn and wait for the folder card.`, + }); + const deadline = Date.now() + 240_000; + let request; + while (request === undefined && Date.now() < deadline) { + const timeline = await call(hostOrigin, 'channelTimeline', { + channelId: channel.id, + limit: 100, + }); + request = timeline.page.entries.find((message) => message.grantRequest !== undefined); + if (request === undefined) await new Promise((done) => setTimeout(done, 1500)); + } + if (request === undefined) throw new Error('the Bot did not ask for a folder'); + steps.push( + 'a real deepseek-v4.1-flash turn asked for a folder through request_workspace_grant', + ); + + const offer = await operator(hostOrigin, '/api/botharness/devices/offer', { + origin: hostOrigin, + }); + const encoded = new URL(offer.link).searchParams.get('p'); + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); + payload.routes = [origin]; + const link = `deepseekbot://pair?p=${Buffer.from(JSON.stringify(payload)).toString('base64url')}`; + + page = await browser.newPage(); + await page.emulate({ + viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148', + }); + page.on('console', (message) => console_.push(`${message.type()}: ${message.text()}`)); + page.on('pageerror', (error) => console_.push(`pageerror: ${error.message}`)); + const text = (value, timeout = 30_000) => + page.waitForFunction( + (needle) => document.body.innerText.includes(needle), + { timeout }, + value, + ); + const shot = async (label) => { + for (const scheme of ['light', 'dark']) { + await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: scheme }]); + await new Promise((done) => setTimeout(done, 400)); + await page.screenshot({ path: join(paths.evidence, `${label}-${scheme}.png`) }); + } + await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'light' }]); + const tree = await page.accessibility.snapshot(); + writeFileSync(join(paths.evidence, `${label}.aria.txt`), JSON.stringify(tree, null, 1)); + }; + + await page.goto(`${origin}/pair`, { waitUntil: 'networkidle0' }); + await text('Or paste the pairing link'); + await page.type( + 'input[placeholder^="deepseekbot://pair"], textarea[placeholder^="deepseekbot://pair"]', + link, + ); + await page.locator('::-p-aria(Pair)').click(); + await text(displayName); + steps.push('paired the app by pasting the link'); + + await page.locator(`::-p-text(${displayName})`).click(); + await text('Choose folder', 60_000); + await page.locator('::-p-aria(Choose folder)').click(); + await text('Other places'); + checks.noTypedPathFallback = !(await page.evaluate(() => + document.body.innerText.includes('cannot list its folders'), + )); + checks.chooserRefused = state.chooserRefusals > 0; + checks.homeListed = await page.evaluate(() => + document.body.innerText.includes('bh-folder-browse-qa'), + ); + await shot('1-browse-home'); + steps.push('the folder sheet listed the computer home through BotHarness, not a typed path'); + + await page.locator('::-p-text(bh-folder-browse-qa)').click(); + await text('notes'); + const listed = await page.evaluate(() => document.body.innerText); + checks.foldersOnly = listed.includes('site') && !listed.includes('README.md'); + checks.hiddenSkipped = !listed.includes('.cache'); + await shot('2-browse-folder'); + steps.push('opened a folder: subfolders only, no files and no hidden folders'); + + await page.locator('::-p-aria(Authorize this folder)').click(); + await text('Allow read/write'); + await shot('3-confirm'); + await page.locator('::-p-aria(Allow read/write)').click(); + const grantDeadline = Date.now() + 30_000; + let granted = false; + while (!granted && Date.now() < grantDeadline) { + const { grants } = await call(hostOrigin, 'grants', { slug: bot.slug }); + granted = grants.some((grant) => grant.workspacePath === fixture && grant.orchestratorWrite); + if (!granted) await new Promise((done) => setTimeout(done, 500)); + } + checks.grantedChosenFolder = granted; + steps.push('confirmed; the Host granted read/write on the chosen folder'); + + const log = console_.join('\n'); + writeFileSync(join(paths.evidence, 'console.txt'), redact(log)); + checks.noPageErrors = !log.includes('pageerror:'); + } catch (error) { + await page?.screenshot({ path: join(paths.evidence, 'failure.png') }).catch(() => {}); + writeFileSync(join(paths.evidence, 'failure.console.txt'), redact(console_.join('\n'))); + throw error; + } finally { + await browser.close(); + proxy.close(); + rmSync(fixture, { recursive: true, force: true }); + if (!keep) { + await stop(instance.pid).catch(() => {}); + rmSync(home, { recursive: true, force: true }); + } + } + const pass = Object.values(checks).every(Boolean); + process.stdout.write( + `${JSON.stringify({ result: pass ? 'pass' : 'fail', checks, steps, evidence: paths.evidence }, null, 2)}\n`, + ); + if (!pass) process.exitCode = 1; +} + +main().catch((error) => { + process.stdout.write( + `${JSON.stringify({ result: 'fail', error: redact(error.message), steps, checks }, null, 2)}\n`, + ); + process.exitCode = 1; +}); diff --git a/scripts/verification-inventory.json b/scripts/verification-inventory.json index f594b264b3..f2a41b31d3 100644 --- a/scripts/verification-inventory.json +++ b/scripts/verification-inventory.json @@ -1587,6 +1587,14 @@ "task": 1967, "sha256": "8f4c330494a2645f477b22141b77266c00ce8ef31144409c0160e22668eda251" }, + { + "path": "scripts/e2e-mobile-folder-browse-web.mjs", + "category": "mapped", + "behaviors": ["desktop.grants.request-write-continue"], + "reason": "Own task Host and Expo Web export; real model folder request, system-chooser refusal simulated at the phone proxy, phone browses Host folders and grants one; not native-device evidence.", + "task": 2080, + "sha256": "74fac4cc7686abd76dceb9fc50e950691eb5d3321261f57132a562be3e2b648d" + }, { "path": "scripts/e2e-mobile-working-web.mjs", "category": "historical-regression",