Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/verify-deepseekbot/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Original file line number Diff line number Diff line change
Expand Up @@ -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 <private-report.json>` 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 <baseline-instance.json>` 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 [<path>]`. 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.
Expand All @@ -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).
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 33 additions & 10 deletions apps/mobile/src/host/bot-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface HostFolderListing {
crumbs: HostFolder[];
entries: HostFolder[];
truncated: boolean;
roots: HostFolder[];
}

export type AccessMode = 'workspace-write' | 'danger-full-access';
Expand Down Expand Up @@ -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<HostClient>();

export async function listHostFolders(
client: HostClient,
path?: string,
): Promise<HostFolderListing> {
const value = record(
await client.call<unknown>('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<unknown>('directoryPicker/list', args));
} catch (error) {
if (!folderBrowsingUnavailable(error)) throw error;
try {
const value = listing(await client.call<unknown>('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<unknown>('folderBrowse', args));
}

function option(value: unknown): WorkspaceOption | undefined {
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/ui/chat/folder-picker-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HostFolderListing>;
Expand Down Expand Up @@ -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 (
<Sheet
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/ui/chat/workspace-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ type BrowseRow =

const OFF = 'off';

export function locationSection(listing: HostFolderListing) {
const data = listing.roots
.map((root, index) => ({
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<ApprovalTemplate, 'name' | 'title'>): string {
if (template.name === 'cautious') return t.autoApprovalCautious;
if (template.name === 'get-work-done') return t.autoApprovalGetWorkDone;
Expand Down Expand Up @@ -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 (
<Sheet
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/ui/primitives/app-dialog-host.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function AppDialogHost() {
return null;
}
8 changes: 6 additions & 2 deletions apps/mobile/src/ui/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,9 @@ const en = {
browseTruncated: 'Too many folders; only some are shown.',
browseUnavailable: 'Your computer cannot list its folders here.',
browseUnavailableTypePath:
'Your computer opens its own folder chooser, so it cannot list folders on this phone. Pick a registered folder or type the folder path.',
'Your computer cannot list its folders here. Update DeepSeekBot on it, pick a registered folder or type the folder path.',
browseLocations: 'Other places',
browseHome: 'Home',
folderPath: 'Folder path on your computer',
folderPathHint: '/Users/you/Project',
registeredFolders: 'Folders registered on your computer',
Expand Down Expand Up @@ -2058,7 +2060,9 @@ const zh: typeof en = {
browseTruncated: '文件夹过多,仅显示一部分。',
browseUnavailable: '电脑无法在这里列出文件夹。',
browseUnavailableTypePath:
'这台电脑使用系统自带的文件夹选择器,无法在手机上列出文件夹。请选择已登记的文件夹,或输入文件夹路径。',
'这台电脑无法在这里列出文件夹。请更新电脑上的 DeepSeekBot,或选择已登记的文件夹、输入文件夹路径。',
browseLocations: '其他位置',
browseHome: '主目录',
folderPath: '电脑上的文件夹路径',
folderPathHint: '/Users/you/Project',
registeredFolders: '已在电脑上登记的文件夹',
Expand Down
55 changes: 55 additions & 0 deletions apps/mobile/test/bot-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,61 @@ describe('Bot workspace from the composer', () => {
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) => {
Expand Down
37 changes: 36 additions & 1 deletion apps/mobile/test/grant-request-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions changes/2392-phone-folder-browse.md
Original file line number Diff line number Diff line change
@@ -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))。
10 changes: 10 additions & 0 deletions docs/bot-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ deepseekbot ask-secret-revoke <channel-id> --message-id <configured-card-id>
deepseekbot ask-secret-accounts <bot-id>
deepseekbot release-info [--since <version>]
deepseekbot workspace-options
deepseekbot folder-browse [<folder-path>]
deepseekbot grant-create <bot-id> --workspace <workspace-id>
```

Expand Down Expand Up @@ -721,6 +722,15 @@ deepseekbot grants <bot-id>

`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`.
Expand Down
10 changes: 10 additions & 0 deletions docs/bot-cli.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ deepseekbot user-question-status <channel-id> --message-id <卡片-id>
deepseekbot user-question-answer <channel-id> --message-id <卡片-id> --answer-stdin
deepseekbot release-info [--since <版本>]
deepseekbot workspace-options
deepseekbot folder-browse [<文件夹路径>]
deepseekbot grant-create <bot-id> --workspace <workspace-id>
```

Expand Down Expand Up @@ -707,6 +708,15 @@ deepseekbot grants <bot-id>

`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`。
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/bots/cli-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading