Merge pull request #5245 from deepseek-harness/feat/cua-permission-onboarding
feat(cua): 持久化应用授权并完善插件启用体验
This commit is contained in:
commit
bf02b08da3
64 files changed
+1305
-439
No files matched your search
@@ -9,11 +9,11 @@
|
||||
en: b32a7ce6483b9b48
|
||||
zh: ec0938ad032b99d8
|
||||
/agent-note-application-consent-for-native-computer-use/decision:
|
||||
en: 36c560c24c376f12
|
||||
zh: 5e4ff72f66574e38
|
||||
en: 28ec4cc587ba487b
|
||||
zh: 8b89bbdff8eff6eb
|
||||
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
|
||||
en: 80cd5fd93a42d937
|
||||
zh: 2a0e241b1b8c2e1d
|
||||
en: 9f41ecc5e34efe17
|
||||
zh: b30ba6dae25b20b2
|
||||
/agent-note-application-consent-for-native-computer-use/consequences:
|
||||
en: 4bda3366af65250a
|
||||
zh: 7c83561b4db07284
|
||||
@@ -10,7 +10,7 @@ macOS privileges authorize an execution host, not a user's choice of target appl
|
||||
|
||||
## Decision
|
||||
|
||||
The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and the provider's existing profile configuration for saved grants. The existing question UI presents the offered labels; the executor rejects custom text or answers that do not select exactly one offered label. Permission-page icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
|
||||
The CUA executor owns application consent for every object-API and native-tool dispatch. It uses the existing User Questions service for human decisions and profile-scoped domain storage for saved grants. Its fixed approval presentation reuses the tool-approval card without adopting the session's tool-approval policy. Native icons and names are presentation metadata, excluded from saved identities and model results. General tool approval and operating-system privileges remain separate requirements. Arbitrary persistent PTC code and separately authorized shell tools retain their own execution authority.
|
||||
|
||||
System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice.
|
||||
|
||||
@@ -18,9 +18,7 @@ A saved identity combines the canonical executable path with a verified designat
|
||||
|
||||
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses.
|
||||
|
||||
Session decisions are kept in memory by Session ID and application identity. Agent disposal and helper replacement release native handles without discarding these decisions; provider disposal or DSH restart clears them. This scope covers follow-up turns without relying on the model to decide when a user intent has ended.
|
||||
|
||||
Selections of the same application within one session are serialized, so a pending question does not produce competing decisions. Saved-grant additions and revocations share one mutation queue and read the current grant list inside it. Provider shutdown cancels writes still waiting for HMR admission; admitted writes settle before disposal completes. Refusals prevent another prompt until the user permits asking again; a restored saved grant takes precedence in both dispatch and the permission-page projection.
|
||||
Conversation decisions survive provider disposal and DSH restart according to [profile consent storage](2026-10-19-cua-profile-consent-storage.md). That decision owns persistence, migration and cross-process revocation. The executor continues to serialize application selections within a conversation, refuse repeated prompts after denial, and give an explicit long-term grant precedence over conversation refusals.
|
||||
|
||||
Revocation invalidates the Agent connections that retain the application's handles, then awaits native cleanup. Reauthorization uses a fresh connection, so old app and window snapshots cannot become valid again. The [native object decision](2026-09-17-experimental-cua-object-api.md) continues to own process identity, coordinate semantics, and separation from the evaluator.
|
||||
|
||||
@@ -30,7 +28,7 @@ Revocation invalidates the Agent connections that retain the application's handl
|
||||
|
||||
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise.
|
||||
|
||||
**Add an approval database or Session control stream.** Profile settings already own user configuration, and User Questions already owns interactive answers. The permission UI observes the provider and its owning settings namespace without another authorization store or event channel.
|
||||
**Add an approval database or Session control stream.** User Questions already owns interactive answers. The existing domain service provides durable state without introducing a dedicated database or Session control stream; storage tradeoffs are owned by the profile consent decision.
|
||||
|
||||
**Disable permission buttons based on the browser address.** A user may operate the DSH host through screen sharing and complete authorization there. Port forwarding and proxies make the address an unreliable indication of whether the browser and DSH run on the same computer.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ macOS 权限授权执行宿主,不能表达用户允许操作哪些目标应
|
||||
|
||||
## 决策
|
||||
|
||||
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在 provider 的现有 profile 配置中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。现有问答界面展示授权选项;执行器拒绝自由文本或未准确选中一个所给选项的回答。权限页图标和名称仅用于展示,不进入保存的身份信息或模型结果。
|
||||
CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。人工决定复用 User Questions 服务,长期许可保存在按 profile 隔离的存储域中。通用工具审批和操作系统权限仍是独立要求。任意 persistent PTC 代码和另行授权的 shell 工具保留各自的执行权限。 固定审批呈现复用工具审批卡片,同时保留独立于会话工具审批策略的授权规则。本机图标和名称仅用于展示,不进入保存的身份信息或模型结果。
|
||||
|
||||
系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。
|
||||
|
||||
@@ -18,9 +18,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
|
||||
|
||||
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
|
||||
|
||||
会话决定按 Session ID 和应用身份保存在内存中。Agent 销毁或 helper 重建会释放原生句柄,但保留授权决定;提供方卸载或 DSH 重启后清除。会话范围可以覆盖用户的后续指令,无须由模型判断一次用户意图何时结束。
|
||||
|
||||
同一会话对同一应用的选择串行执行,避免未完成的问题产生相互冲突的决定。长期许可的新增和撤销共用写入队列,并在队列内读取当前许可列表。提供方关闭时取消尚在等待 HMR 调度的写入,已开始的写入会在销毁完成前结束。拒绝会阻止再次询问,直到用户允许重询;重新添加的长期许可在执行检查和权限页中均优先于旧拒绝记录。
|
||||
会话决定按[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)的规则,在提供方卸载和 DSH 重启后保留。该记录负责持久化、迁移和跨进程撤销。执行器继续将同一对话对同一应用的选择串行化,拒绝后不再次询问,显式长期许可优先于会话拒绝。
|
||||
|
||||
撤销使保留目标应用句柄的 Agent 连接失效,并等待原生清理完成。重新授权使用新连接,因此旧应用对象和窗口快照不能重新生效。[原生对象决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责进程身份、坐标语义和与执行器分离的设计。
|
||||
|
||||
@@ -30,7 +28,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
|
||||
|
||||
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。
|
||||
|
||||
**新增审批数据库或 Session 控制流。** Profile settings 已负责用户配置,User Questions 已负责交互回答。权限界面查询 provider,并监听其配置 namespace,无须另一份授权存储或事件通道。
|
||||
**增加审批数据库或 Session 控制流。** User Questions 已经负责交互式回答。现有域服务提供持久状态,无须增加专用数据库或 Session 控制流;存储取舍由 profile 授权存储决策负责。
|
||||
|
||||
**根据浏览器访问地址禁用权限按钮。** 用户可能通过屏幕共享操作运行 DSH 的电脑,并在该电脑上完成授权。端口转发和代理使访问地址无法可靠判断浏览器与 DSH 是否运行在同一台电脑上。
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-cua-profile-consent-storage.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-cua-profile-consent-storage.md
|
||||
/agent-note-durable-computer-use-consent-within-one-profile:
|
||||
en: b2aa270d586efd93
|
||||
zh: 99606fa28dc4a752
|
||||
/agent-note-durable-computer-use-consent-within-one-profile/problem:
|
||||
en: f2c70036a57ddb41
|
||||
zh: 9bbbf3dd09d486f5
|
||||
/agent-note-durable-computer-use-consent-within-one-profile/decision:
|
||||
en: 1df102f3d0440a6a
|
||||
zh: 3456af8e552729a3
|
||||
/agent-note-durable-computer-use-consent-within-one-profile/alternatives-considered:
|
||||
en: 2af74afa3ecaf3b9
|
||||
zh: 96f7f43c5d1150df
|
||||
/agent-note-durable-computer-use-consent-within-one-profile/consequences:
|
||||
en: 66fe232589e46d64
|
||||
zh: fc69831be76248d9
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Durable Computer Use consent within one profile
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-cua-profile-consent-storage.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Application grants stored as plugin configuration make each authorization change depend on configuration reconciliation. Process-local conversation decisions also disappear when the same conversation resumes after a restart. A shared in-memory storage handle cannot observe another process revoking permission.
|
||||
|
||||
## Decision
|
||||
|
||||
The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [application consent](2026-09-26-cua-application-consent.md); this note replaces only that note's storage and restart-lifetime choices.
|
||||
|
||||
Every operation opens fresh domain state while holding the profile's existing file-lock primitive and closes the domain before releasing the lock. Native dispatch and revocation use a separate lock for the selected application. Storage reads and unrelated applications remain available during a native call. Revocation cancels this process's affected work first, then commits after other processes' already-admitted operations have finished. A completed revocation therefore leaves no operation using the old authority. App-wide revocation generations and per-conversation decision revisions prevent an old handle or delayed answer from restoring revoked access.
|
||||
|
||||
The JSON backend remains the shipped default. The domain name contains the SHA-256 digest of the canonical profile directory, isolating profiles that share one storage root. Legacy `allowedApps` is imported once; a persisted migration marker prevents an old configuration from restoring a revoked grant. Users can remove that configuration after checking the migrated list. Corrupt or unavailable profile storage fails closed. A composition without a profile has only temporary conversation state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep authorization in configuration.** It preserves existing storage but retains configuration-write and HMR coupling for runtime application decisions.
|
||||
|
||||
**Keep one domain open and rely on change events.** Domain notifications are process-local. Another host could continue to use stale grants after revocation.
|
||||
|
||||
**Add a new watcher, revision file or authorization broker.** The existing domain and file-lock primitives provide fresh reads and serialized operation admission without a new shared storage or IPC framework. Operations on the same application are serialized across processes; other applications and their conversation decisions remain independent.
|
||||
|
||||
## Consequences
|
||||
|
||||
File locks coordinate processes on one machine and PID namespace. A network-mounted profile shared across hosts is unsupported. Revocation may wait for another process's native call to settle. No timer establishes authorization validity.
|
||||
|
||||
The permission page still omits conversation-only grants and reads current state on entry, focus, actions and reconnection. Storage changes do not emit configuration events. System permissions remain owned by macOS. Tests cover restart retention, profile isolation, another process's write, cancellation, corrupted data, legacy import and non-resurrection after revocation. Stored conversation records currently require explicit retention management rather than automatic eviction.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 同一 profile 内的持久 Computer Use 授权
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-cua-profile-consent-storage.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
把应用许可保存为插件配置,会让每次授权变更依赖配置重组。只保存在进程内的会话决定,也会在重启后恢复同一对话时丢失。长期持有的内存存储句柄无法观察另一个进程的撤销操作。
|
||||
|
||||
## 决策
|
||||
|
||||
CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[应用授权](2026-09-26-cua-application-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。
|
||||
|
||||
每个操作使用现有文件锁,在 profile 锁内打开并读取最新存储状态,关闭域后才释放锁。原生操作和撤销使用单独的目标应用锁;原生调用期间仍可读取存储或访问其他应用。撤销先取消本进程中受影响的工作,再等待其他进程已获准执行的操作结束后提交。因此,撤销完成后不会留下仍使用旧授权的操作。应用级撤销代次和各对话的决定版本,阻止旧句柄或迟到回答恢复被撤销的访问权限。
|
||||
|
||||
随附配置继续使用 JSON 后端。域名包含 profile 真实目录的 SHA-256 摘要,隔离共用存储根目录的不同 profile。旧 `allowedApps` 只导入一次,持久化迁移标记阻止旧配置恢复已撤销的许可。用户确认列表迁移完成后可删除旧配置。profile 存储损坏或不可用时拒绝访问。没有 profile 的组合只保留临时会话状态。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续将授权保存在配置中。** 可以复用旧存储,但运行时应用决定仍依赖配置写入和 HMR。
|
||||
|
||||
**长期打开域并依赖变更事件。** 域事件只在进程内传播,另一个宿主可能在撤销后继续使用旧许可。
|
||||
|
||||
**增加新的监听器、版本文件或授权代理。** 现有域存储和文件锁已经能提供最新状态与串行准入,无须增加通用存储或 IPC 框架。不同进程对同一应用的操作串行执行,其他应用及其会话决定保持独立。
|
||||
|
||||
## 后果
|
||||
|
||||
文件锁协调同一电脑及 PID 命名空间中的进程,不支持跨主机共用网络目录。撤销可能等待其他进程的原生调用结束。授权是否有效不由计时器决定。
|
||||
|
||||
权限页仍不显示仅对单个会话生效的许可,在进入页面、返回窗口、完成操作和重连后读取最新状态。存储变更不发送配置事件,系统权限仍归 macOS 管理。测试覆盖重启恢复、profile 隔离、其他进程写入、取消、数据损坏、旧配置导入和撤销后不恢复。已保存的会话记录目前需要显式管理保留期限,不自动淘汰。
|
||||
+153
-75
@@ -1,4 +1,4 @@
|
||||
/** Optional CUA uses existing plugin activation and offers a separate persistent PTC action. */
|
||||
/** CUA follows PTC Agent scopes and changes the default only from its detail page. */
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -8,45 +8,37 @@ import { readProfileManifest } from '@deepseek-ai/dsh-app-boot'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { load } from 'js-yaml'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
// The shipped bundle admits only macOS Hosts; unsupported Hosts are exercised in bundle-platform.e2e.ts.
|
||||
it.skipIf(process.platform !== 'darwin').each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => {
|
||||
it.skipIf(process.platform !== 'darwin').each([
|
||||
{ selectedDefault: undefined, codingTools: false },
|
||||
{ selectedDefault: undefined, codingTools: true },
|
||||
{ selectedDefault: 'minimal', codingTools: true },
|
||||
] as const)('preserves Coding Tools=$codingTools and default=$selectedDefault across CUA toggles', async ({ selectedDefault, codingTools }) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'cua-optin-web-'))
|
||||
let ownedScaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
const patch = join(directory, 'native-fixture.yml')
|
||||
// The executable cannot answer native permission queries, so setup must offer retry in the detail page.
|
||||
await writeFile(patch, JSON.stringify([{ id: 'cua', disabled: false, config: { helperPath: process.execPath } }]))
|
||||
// Install the test provider explicitly so the helper override also exercises session wiring on Linux.
|
||||
const scaffold = ownedScaffold = await launchWebScaffold({
|
||||
profile: { packages: [{ dir: fileURLToPath(new URL('../../../packages/experimental/cua-macos', import.meta.url)), enabled: false }] },
|
||||
harnessHome: join(directory, 'home'), extraOverlayPath: [
|
||||
fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch,
|
||||
] })
|
||||
// Permission inspection fails against this non-helper executable; activation still permits dismissing setup.
|
||||
await writeFile(patch, JSON.stringify([{ id: 'cua', config: { helperPath: process.execPath } }]))
|
||||
const harnessHome = join(directory, 'home')
|
||||
const scaffold = ownedScaffold = await launchWebScaffold({ harnessHome, extraOverlayPath: [
|
||||
fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), patch,
|
||||
] })
|
||||
const initialDefault = selectedDefault ?? 'standard'
|
||||
if (selectedDefault !== undefined) await scaffold.ctx.settings.update('agent-preset-registry', { selectedDefault })
|
||||
await scaffold.ctx.settings.update('ui-settings', { enabled: codingTools })
|
||||
const settingsPath = join(harnessHome, 'profiles/scaffold/cordis.patch.yml')
|
||||
const beforeEnable = await readFile(settingsPath, 'utf8')
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
const originalAgent = scaffold.ctx.agents.list()[0]!
|
||||
const ptcAgent = (await scaffold.ctx.agents.create({ sessionId: SessionId('cua-existing-ptc'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') },
|
||||
})).agent
|
||||
const runtimeFiber = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')?.fiber
|
||||
const originalRuntime = scaffold.ctx.ptcRuntime
|
||||
const originalRuntimeFiber = runtimeFiber()
|
||||
expect(originalRuntimeFiber).toBeDefined()
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
const settingsPath = join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml')
|
||||
const before = await readFile(settingsPath, 'utf8')
|
||||
const profileManifest = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold'))
|
||||
const selected = () => profileManifest().dsh?.profile?.bundles
|
||||
const dependencies = profileManifest().dependencies
|
||||
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
|
||||
const plugins = page.locator('[data-plugin-panel]')
|
||||
await plugins.getByRole('button', { name: '更多', exact: true }).waitFor()
|
||||
@@ -68,7 +60,22 @@ it.skipIf(process.platform !== 'darwin').each([undefined, 'minimal'] as const)('
|
||||
return ready.getByRole('button', { name: '立即启用', exact: true })
|
||||
}
|
||||
const enableNow = await offerCua()
|
||||
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
let toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
const originalAgent = scaffold.ctx.agents.list()[0]!
|
||||
const ptcAgent = (await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('cua-existing-ptc'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') },
|
||||
})).agent
|
||||
expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(false)
|
||||
const originalRuntime = scaffold.ctx.ptcRuntime
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
expect(originalRuntime.programFormat).toBe('function-body')
|
||||
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
|
||||
const profileManifest = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold'))
|
||||
const selected = () => profileManifest().dsh?.profile?.bundles
|
||||
const dependencies = profileManifest().dependencies
|
||||
expect(selected()).not.toContain('@deepseek-ai/dsh-cua-bundle')
|
||||
|
||||
const enabling = Promise.withResolvers<undefined>()
|
||||
const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager)
|
||||
let applying: ReturnType<typeof setEnabled> | undefined
|
||||
@@ -95,76 +102,147 @@ it.skipIf(process.platform !== 'darwin').each([undefined, 'minimal'] as const)('
|
||||
const setup = page.getByRole('dialog', { name: '使用 Computer Use 前需要设置', exact: true })
|
||||
await setup.waitFor()
|
||||
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
|
||||
await setup.getByRole('button', { name: '稍后', exact: true }).click()
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/setup.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
expect(await setup.getByRole('button', { name: '设为 PTC', exact: true }).count()).toBe(0)
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
expect(selected()).toContain('@deepseek-ai/dsh-cua-bundle')
|
||||
expect(profileManifest().dependencies).toEqual(dependencies)
|
||||
expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
|
||||
expect(await card.textContent()).not.toContain('将 PTC 模式改为可持久化')
|
||||
expect(await card.textContent()).toContain('请切换到 PTC 模式')
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(before)
|
||||
expect(runtimeFiber() === originalRuntimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
expect(await card.textContent()).toContain('Windows 支持正在开发中')
|
||||
expect(await card.textContent()).not.toContain('Swift')
|
||||
const description = await card.getByRole('button', { name: '查看 Computer Use', exact: true }).evaluate((button) => {
|
||||
const element = document.getElementById(button.getAttribute('aria-describedby')!)!
|
||||
const style = getComputedStyle(element)
|
||||
return { lines: style.webkitLineClamp, height: element.getBoundingClientRect().height,
|
||||
lineHeight: Number.parseFloat(style.lineHeight) }
|
||||
})
|
||||
expect(description.lines).toBe('1')
|
||||
expect(description.height).toBeLessThanOrEqual(description.lineHeight + 1)
|
||||
expect(scaffold.ctx.get('computerUse')).toBeDefined()
|
||||
expect(scaffold.ctx.agents.get(originalAgent.id) === originalAgent).toBe(true)
|
||||
expect(scaffold.ctx.agents.get(ptcAgent.id) === ptcAgent).toBe(true)
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault)
|
||||
expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(false)
|
||||
expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(true)
|
||||
expect((await scaffold.ctx.systemPrompt.assemble({ scope: ptcAgent })).tools.map(tool => tool.name)).toEqual(['run_code'])
|
||||
const fresh = await scaffold.ctx.agents.create({ sessionId: SessionId('cua-new-ptc'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') },
|
||||
for (const preset of ['ptc', 'standard', 'ptc', initialDefault]) {
|
||||
await scaffold.ctx.agentPresets.select(originalAgent, preset)
|
||||
expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(preset === 'ptc')
|
||||
}
|
||||
const child = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('cua-child'), parentAgent: ptcAgent,
|
||||
setup: (ctx) => { scaffold.ctx.agentPresets.composeFrom(ctx, ptcAgent.ctx) },
|
||||
})
|
||||
expect(scaffold.ctx.tools.schemas(fresh.agent).some(tool => tool.name === 'cua')).toBe(true)
|
||||
await toggle.click()
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
|
||||
for (const agent of [ptcAgent, fresh.agent]) expect(scaffold.ctx.tools.schemas(agent).some(tool => tool.name === 'cua')).toBe(false)
|
||||
expect(runtimeFiber() === originalRuntimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(true)
|
||||
await child.dispose()
|
||||
expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(false)
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(before)
|
||||
await (await offerCua()).click()
|
||||
await setup.waitFor()
|
||||
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
|
||||
await setup.getByRole('button', { name: '稍后', exact: true }).click()
|
||||
await card.getByRole('button', { name: '查看 Computer Use', exact: true }).click()
|
||||
const detailToggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
const panel = page.locator('[data-cua-settings]')
|
||||
await panel.waitFor()
|
||||
const defaultSetting = panel.getByRole('group', { name: '新会话默认模式', exact: true })
|
||||
await expect.poll(() => defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).isEnabled()).toBe(true)
|
||||
await defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.agentPresets.defaultId).toBe('ptc')
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault)
|
||||
expect(runtimeFiber() === originalRuntimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('function-body')
|
||||
const confirmed = await scaffold.ctx.agents.create({ sessionId: SessionId('cua-confirmed-default'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) },
|
||||
})
|
||||
expect(scaffold.ctx.tools.schemas(confirmed.agent).some(tool => tool.name === 'cua')).toBe(true)
|
||||
const saved = await readFile(settingsPath, 'utf8')
|
||||
expect(saved).toContain('selectedDefault: ptc')
|
||||
await setup.waitFor({ state: 'hidden' })
|
||||
let saved = await readFile(settingsPath, 'utf8')
|
||||
expect(saved).toBe(beforeEnable)
|
||||
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: codingTools })
|
||||
expect(saved).not.toContain('executionMode:')
|
||||
const persistent = panel.getByRole('group', { name: 'PTC 执行方式', exact: true })
|
||||
if (selectedDefault === undefined) {
|
||||
expect(load(saved)).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'agent-preset-registry' })]))
|
||||
} else {
|
||||
expect(load(saved)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault } }),
|
||||
]))
|
||||
}
|
||||
const freshDefault = (await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('cua-new-default'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) },
|
||||
})).agent
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(freshDefault.ctx)).toBe(initialDefault)
|
||||
expect(scaffold.ctx.tools.schemas(freshDefault).some(tool => tool.name === 'cua')).toBe(false)
|
||||
const fresh = (await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('cua-new-ptc'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx, 'ptc') },
|
||||
})).agent
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(fresh.ctx)).toBe('ptc')
|
||||
expect(scaffold.ctx.tools.schemas(fresh).some(tool => tool.name === 'cua')).toBe(true)
|
||||
await card.getByRole('button', { name: '查看 Computer Use', exact: true }).click()
|
||||
toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: '启用 Computer Use', exact: true })
|
||||
const persistent = page.getByRole('group', { name: 'PTC 执行方式', exact: true })
|
||||
const beforePersistent = scaffold.ctx.ptcRuntime
|
||||
await persistent.getByRole('button', { name: '设为持久化', exact: true }).click()
|
||||
await persistent.getByRole('button', { name: '已设为持久化', exact: true }).waitFor()
|
||||
expect(scaffold.ctx.ptcRuntime.programFormat).toBe('repl')
|
||||
expect(scaffold.ctx.ptcRuntime === originalRuntime).toBe(false)
|
||||
const persistentRuntimeFiber = runtimeFiber()
|
||||
const runtime = scaffold.ctx.ptcRuntime, owner = {}
|
||||
expect(scaffold.ctx.ptcRuntime === beforePersistent).toBe(false)
|
||||
saved = await readFile(settingsPath, 'utf8')
|
||||
expect(saved).toContain('executionMode: persistent-in-memory')
|
||||
const runtime = scaffold.ctx.ptcRuntime
|
||||
const runtimeFiber = [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber
|
||||
const owner = {}
|
||||
const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1'
|
||||
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1)
|
||||
const configured = await readFile(settingsPath, 'utf8')
|
||||
expect(configured).toContain('executionMode: persistent-in-memory')
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe('ptc')
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault)
|
||||
await detailToggle.click()
|
||||
await panel.waitFor({ state: 'hidden' })
|
||||
await toggle.click()
|
||||
await toggle.waitFor({ state: 'hidden' })
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(configured)
|
||||
expect(runtimeFiber() === persistentRuntimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
|
||||
expect([...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber === runtimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
expect(scaffold.ctx.agents.get(ptcAgent.id) === ptcAgent).toBe(true)
|
||||
for (const agent of [ptcAgent, fresh]) {
|
||||
expect(scaffold.ctx.tools.schemas(agent).some(tool => tool.name === 'cua')).toBe(false)
|
||||
expect((await scaffold.ctx.systemPrompt.assemble({ scope: agent })).tools.map(tool => tool.name)).toEqual(['run_code'])
|
||||
}
|
||||
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2)
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(saved)
|
||||
await (await offerCua()).click()
|
||||
await panel.waitFor({ state: 'visible' })
|
||||
await detailToggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(configured)
|
||||
expect(runtimeFiber() === persistentRuntimeFiber).toBe(true)
|
||||
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
await setup.waitFor()
|
||||
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
await setup.getByRole('button', { name: '前往设置', exact: true }).click()
|
||||
await setup.waitFor({ state: 'hidden' })
|
||||
await page.getByRole('region', { name: '给 Computer Use 授权', exact: true }).getByRole('alert').waitFor()
|
||||
if (!codingTools) {
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(saved)
|
||||
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: false })
|
||||
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
|
||||
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(3)
|
||||
await runtime.release(owner)
|
||||
return
|
||||
}
|
||||
const defaultSetting = page.getByRole('group', { name: '新会话默认模式', exact: true })
|
||||
await expect.poll(() => defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).isEnabled()).toBe(true)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/default-mode.expected.md', import.meta.url)),
|
||||
await captureStableAria(page, '[role="group"][aria-label="新会话默认模式"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await defaultSetting.getByRole('button', { name: '设为 PTC', exact: true }).click()
|
||||
await expect.poll(() => scaffold.ctx.agentPresets.defaultId).toBe('ptc')
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(originalAgent.ctx)).toBe(initialDefault)
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(freshDefault.ctx)).toBe(initialDefault)
|
||||
const confirmed = (await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('cua-confirmed-default'),
|
||||
setup: async (ctx) => { await scaffold.ctx.agentPresets.mount(ctx) },
|
||||
})).agent
|
||||
expect(scaffold.ctx.agentPresets.composedPreset(confirmed.ctx)).toBe('ptc')
|
||||
expect(scaffold.ctx.tools.schemas(confirmed).some(tool => tool.name === 'cua')).toBe(true)
|
||||
expect([...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'ptc-runtime')!.fiber === runtimeFiber).toBe(true)
|
||||
expect(scaffold.ctx.tools.schemas(ptcAgent).some(tool => tool.name === 'cua')).toBe(true)
|
||||
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(3)
|
||||
const confirmedSettings = await readFile(settingsPath, 'utf8')
|
||||
expect(load(confirmedSettings)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault: 'ptc' } }),
|
||||
]))
|
||||
await toggle.click()
|
||||
await toggle.waitFor({ state: 'hidden' })
|
||||
await card.waitFor({ state: 'hidden' })
|
||||
await (await offerCua()).click()
|
||||
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
|
||||
await setup.waitFor()
|
||||
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
|
||||
expect(await setup.getByRole('button', { name: '设为 PTC', exact: true }).count()).toBe(0)
|
||||
expect(await readFile(settingsPath, 'utf8')).toBe(confirmedSettings)
|
||||
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: codingTools })
|
||||
await runtime.release(owner)
|
||||
} finally {
|
||||
try { await browser?.close() } finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Built permission UI talks to the real provider; the isolated helper substitutes only macOS privilege observations. */
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -33,19 +33,16 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' }
|
||||
await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication }))
|
||||
await writeFile(join(root, 'observe-helper'), '')
|
||||
const fixtureBundle = join(root, 'fixture-bundle')
|
||||
await mkdir(fixtureBundle)
|
||||
await writeFile(join(fixtureBundle, 'package.json'), JSON.stringify({ name: 'cua-permission-fixture', version: '1.0.0',
|
||||
dsh: { bundle: { patch: 'cordis.patch.yml' } } }))
|
||||
await writeFile(join(fixtureBundle, 'cordis.patch.yml'), `- id: cua\n config: ${JSON.stringify({ helperPath: helper })}\n`)
|
||||
const fixturePatch = join(root, 'native-fixture.yml')
|
||||
// Keep the external helper override above bundle order changes when CUA is re-enabled.
|
||||
await writeFile(fixturePatch, JSON.stringify([{ id: 'cua', config: { helperPath: helper } }]))
|
||||
const scaffold = await launchWebScaffold({
|
||||
profile: { packages: [{ dir: bundle, enabled: true }, { dir: fixtureBundle, enabled: true }] },
|
||||
profile: { packages: [{ dir: bundle, enabled: true }] },
|
||||
extraOverlayPath: fixturePatch,
|
||||
})
|
||||
resources.scaffold = scaffold
|
||||
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle'))
|
||||
.toMatchObject({ listedByDefault: false, enabled: true, installed: true })
|
||||
await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'],
|
||||
value: [{ id: 'a'.repeat(64), name: 'Fixture', executablePath: '/Applications/Fixture.app' }] }])
|
||||
const browser = await chromium.launch()
|
||||
resources.browser = browser
|
||||
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
|
||||
@@ -55,7 +52,6 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
const agent = scaffold.ctx.agents.list()[0]!
|
||||
await scaffold.ctx.agentPresets.select(agent, 'ptc')
|
||||
scaffold.ctx.approval.setPolicy(agent, 'never')
|
||||
await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }])
|
||||
// Application consent remains independent of the session's tool-approval policy.
|
||||
const grant = scaffold.ctx.tools.execute({ agent, signal: new AbortController().signal,
|
||||
calls: [{ type: 'tool-call', id: ToolCallId('cua-app-approval'), name: 'run_code',
|
||||
@@ -147,7 +143,10 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
await panel.getByRole('button', { name: 'Allow asking again', exact: true }).click()
|
||||
await panel.getByText('Fixture', { exact: true }).waitFor({ state: 'hidden' })
|
||||
const saved = await readFile(join(scaffold.harnessHome, 'profiles/scaffold/cordis.patch.yml'), 'utf8')
|
||||
expect(saved).toContain('allowedApps: []')
|
||||
expect(saved).not.toContain('allowedApps:')
|
||||
const files = await readdir(join(scaffold.workspaceCwd, '.dsh-storages'))
|
||||
expect(files.some(file => file.startsWith('cua_permissions_') && file.endsWith('.json'))).toBe(true)
|
||||
expect((await scaffold.ctx.cuaAccess.applications()).allowedApps).toEqual([])
|
||||
const events = (await readFile(audit, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; method: string; params: object })
|
||||
.filter(record => record.event === 'call').map(({ method, params }) => ({ method, params }))
|
||||
expect(events).toContainEqual({ method: 'requestPermission', params: { permission: 'accessibility' } })
|
||||
@@ -203,6 +202,8 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
|
||||
await expect.poll(() => toggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true)
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true')
|
||||
const setup = page.getByRole('dialog', { name: 'Set up computer use before use', exact: true })
|
||||
await setup.getByRole('button', { name: 'Later', exact: true }).click()
|
||||
await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
|
||||
expect(await trace.evaluate(value => value.errors)).toEqual([])
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
- group "Approval details":
|
||||
- strong: Fixture
|
||||
- text: Allow computer use to launch, read, capture and control this application?
|
||||
- paragraph: Session access lasts until DSH restarts or computer use is disabled.
|
||||
- paragraph: Session access applies only to this conversation.
|
||||
- button "Reject"
|
||||
- button "Allow for this session"
|
||||
- button "Always allow"
|
||||
@@ -0,0 +1,4 @@
|
||||
- group "新会话默认模式":
|
||||
- strong: 新会话默认模式
|
||||
- paragraph: Computer Use 仅在 PTC 模式中可用。
|
||||
- button "设为 PTC"
|
||||
@@ -0,0 +1,6 @@
|
||||
- dialog "使用 Computer Use 前需要设置":
|
||||
- heading "使用 Computer Use 前需要设置" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。
|
||||
- button "稍后"
|
||||
- button "前往设置"
|
||||
@@ -105,7 +105,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `internal/service` | - | `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
|
||||
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
|
||||
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
|
||||
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
|
||||
| `slots/changed` | `ui-renderer` (`emit`) | - |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
| `internal/service` | - | `gateway` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
|
||||
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
|
||||
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
|
||||
| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
|
||||
| `slots/changed` | `ui-renderer` (`emit`) | - |
|
||||
<!-- END GENERATED event-producer-consumer:undeclared -->
|
||||
|
||||
|
||||
@@ -85,10 +85,10 @@ Browser controls for provider-owned native permission and application decisions.
|
||||
@Remote async enablePersistentPtc(): Promise<void>
|
||||
|
||||
/**
|
||||
* Read application decisions without waiting for the native helper.
|
||||
* @returns Saved grants and refused applications with their persistence owner.
|
||||
* Read fresh stored application decisions without issuing a native request.
|
||||
* @returns Saved grants, refused applications and durable-storage availability.
|
||||
*/
|
||||
@Remote applications(): CuaApplicationState
|
||||
@Remote applications(): Promise<CuaApplicationState>
|
||||
|
||||
/**
|
||||
* Inspect system privileges independently of application decisions and icons.
|
||||
|
||||
@@ -85,10 +85,10 @@ Browser controls for provider-owned native permission and application decisions.
|
||||
@Remote async enablePersistentPtc(): Promise<void>
|
||||
|
||||
/**
|
||||
* Read application decisions without waiting for the native helper.
|
||||
* @returns Saved grants and refused applications with their persistence owner.
|
||||
* Read fresh stored application decisions without issuing a native request.
|
||||
* @returns Saved grants, refused applications and durable-storage availability.
|
||||
*/
|
||||
@Remote applications(): CuaApplicationState
|
||||
@Remote applications(): Promise<CuaApplicationState>
|
||||
|
||||
/**
|
||||
* Inspect system privileges independently of application decisions and icons.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.md
|
||||
/:
|
||||
en: 131777d89cb23df3
|
||||
zh: 44e50f8c09b2c4b9
|
||||
/computer-use-consent-storage:
|
||||
en: fb837293708860f5
|
||||
zh: 8ee31126d1faf7c7
|
||||
/computer-use-consent-storage/change:
|
||||
en: 88150a92531212bf
|
||||
zh: a0103c4d035a4af7
|
||||
/computer-use-consent-storage/migration:
|
||||
en: c7a4989a79f4fbca
|
||||
zh: 630fc23128ee0520
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Computer Use application consent moves from profile configuration to durable profile-scoped storage."
|
||||
---
|
||||
|
||||
# Computer Use consent storage
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Computer Use stores application consent through `storageDomain`. With the default JSON backend, each canonical profile directory has its own `$DSH_HOME/storages/cua_permissions_<sha256>.json`. “Allow for this session” now follows the conversation ID across DSH restart and Computer Use disablement. It does not authorize another conversation. Unverifiable applications still require a matching native process identity; restarting that application may require new consent.
|
||||
|
||||
`allowedApps` is a legacy import source. The first store access imports it once. Subsequent configuration edits do not grant or revoke access, and a revoked grant is not restored by reloading the old configuration. The permission page remains the authoring interface for grants and refusals. macOS privileges are unchanged.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Keep the profile's existing storage services mounted. Shipped profiles already include `storageDomain` and the JSON backend; custom profiles need the same services. A profile without domain storage refuses authorization operations.
|
||||
2. Enable Computer Use and open its details to initialize storage. Confirm that the always-allowed applications appear.
|
||||
3. Remove `allowedApps` from the CUA entry in the profile configuration and other overlays after confirmation. Keep the remaining CUA configuration.
|
||||
4. Use the permission page to revoke applications or allow asking again. Existing conversation-only grants held by an older running process cannot be recovered; grant those once after upgrading. Newly granted conversation access then survives restart.
|
||||
5. Keep the storage file with that profile's state. Moving the profile to a different real directory selects a different store. File locks coordinate processes on one machine, not different machines sharing a network filesystem.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Computer Use 应用授权从 profile 配置迁移到按 profile 隔离的持久存储。"
|
||||
---
|
||||
|
||||
# Computer Use 授权存储
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
Computer Use 通过 `storageDomain` 保存应用授权。使用默认 JSON 后端时,每个 profile 的真实目录对应独立的 `$DSH_HOME/storages/cua_permissions_<sha256>.json`。“本会话允许”现在按对话 ID 跨 DSH 重启和 Computer Use 启停保留,不授权其他对话。无法验证签名的应用仍需匹配本机进程身份,应用重启后可能需要重新授权。
|
||||
|
||||
`allowedApps` 作为旧数据来源,只在首次访问存储时导入一次。此后修改配置不会授予或撤销访问权限,重新加载旧配置也不会恢复已撤销的许可。权限页继续提供授权管理和清除拒绝的操作。macOS 系统权限保持原样。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 保留 profile 的现有存储服务。随附 profile 已包含 `storageDomain` 和 JSON 后端,自定义 profile 需要挂载相同服务。有 profile 但没有域存储时,授权操作会被拒绝。
|
||||
2. 启用 Computer Use 并打开详情页,初始化存储。确认始终允许的应用显示正确。
|
||||
3. 确认后,从 profile 配置及其他覆盖层的 CUA 条目中删除 `allowedApps`,保留其他 CUA 配置。
|
||||
4. 在权限页撤销应用授权或允许再次询问。旧进程内已有的会话许可无法恢复,升级后需要重新授予一次;新授予的会话许可随后可跨重启保留。
|
||||
5. 将存储文件作为该 profile 的状态保留。把 profile 移到另一个真实目录后会使用不同存储。文件锁协调同一电脑上的进程,不支持多台电脑共用网络文件系统。
|
||||
@@ -33,8 +33,8 @@
|
||||
en: e9da90f03691101b
|
||||
zh: 902cda935a4ce3c8
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points:
|
||||
en: 54d7666c630da471
|
||||
zh: d7ded257d0fd5559
|
||||
en: d5e3cad5c31cae59
|
||||
zh: 6dc38f09b410d247
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
|
||||
en: fc469bab4fbd6028
|
||||
zh: 8f5a04e1b9a46471
|
||||
@@ -48,8 +48,8 @@
|
||||
en: 21a0bbe8eb92229c
|
||||
zh: 096ff63db61992bb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots:
|
||||
en: 2b34cac9647abe63
|
||||
zh: 1929fc24aee6793c
|
||||
en: 45e324f5663be635
|
||||
zh: 674ddf55c1581569
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/further-exploration:
|
||||
en: f9a42dfed0360d6a
|
||||
zh: 455a70bcb6c69624
|
||||
|
||||
@@ -110,6 +110,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
A row's page exists only while a `plugins.row.config` entry names the row, so a contribution meant for a row renders on the page that configuration opens.
|
||||
|
||||
Component lists and detail pages show current startup diagnostics from the Host inventory. The diagnostic remains visible after reopening the page and disappears when the component recovers. Component and source rows use l2 separators by default; the Computer Use detail page uses l3.
|
||||
|
||||
### Page tabs
|
||||
|
||||
Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
|
||||
@@ -146,7 +148,7 @@ Custom item pages use the Host entry id as their registration id; row pages use
|
||||
|
||||
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins read `pluginNavigation` optionally, through `ctx.get` or `ctx.inject`, and call its `openBundle(packageName)` to open bundle details without changing the current Session; listing it in a plugin's `inject` keeps that plugin pending on a page without access. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [`dsh-official-bundles`](../../util/official-bundles/README.md#plugin-manager-lists); package-name prefixes do not select the marker.
|
||||
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
|
||||
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. Client plugins can subscribe to the read-only `ctx.pluginChanges.bundle(packageName)` source to pause their queries while a bundle or one of its rows is being changed. Notifications precede the management request and continue when the page closes; subscribers release their observation on disposal. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from either the list or detail page, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
||||
|
||||
行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。
|
||||
|
||||
组件列表和详情页显示 Host 清单中的当前启动诊断。重新打开页面后仍能看到原因,组件恢复后诊断消失。组件列表与来源信息默认使用 l2 分割线,Computer Use 详情页使用 l3。
|
||||
|
||||
### 页面标签页
|
||||
|
||||
其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
|
||||
@@ -146,7 +148,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
|
||||
|
||||
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件经 `ctx.get` 或 `ctx.inject` 以可选方式读取 `pluginNavigation`,调用其 `openBundle(packageName)` 即可打开组合包详情,不改变当前 Session;把它写进插件的 `inject` 会让该插件在无权限的页面上一直等待。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [`dsh-official-bundles`](../../util/official-bundles/README.zh.md#plugin-manager-lists) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。
|
||||
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
|
||||
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。Client 插件可订阅只读的 `ctx.pluginChanges.bundle(packageName)`,在组合包或其组件行变更期间暂停查询。通知先于管理请求发出,页面关闭后仍会更新;订阅方在卸载时释放观察。`plugins.bundle.activation` 在用户从列表或详情页显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1577,6 +1577,11 @@ label.registryOption {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.detail[data-plugin-detail='@deepseek-ai/dsh-cua-bundle'] .facts div,
|
||||
.detail[data-plugin-detail='@deepseek-ai/dsh-cua-bundle'] .row {
|
||||
border-bottom-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.rowLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -251,6 +251,11 @@ function MetadataError({ error, t }: { readonly error: string | undefined; reado
|
||||
return error === undefined ? null : <p className={css.reason} role="status" data-package-meta-error>{t('metadataError', { error })}</p>
|
||||
}
|
||||
|
||||
/** A failed component keeps its current Host diagnostic beside its controls. */
|
||||
function RuntimeError({ error, t }: { readonly error: string | undefined; readonly t: Translate }): ReactNode {
|
||||
return error === undefined ? null : <p className={css.reason} role="status" data-plugin-runtime-error>{t('reasonLabel')}: {error}</p>
|
||||
}
|
||||
|
||||
/**
|
||||
* A pack's rows as a list in the order the pack declares them: a state dot,
|
||||
* the row id, one line saying its state, a configure control for a row that
|
||||
@@ -327,6 +332,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: {
|
||||
/>}
|
||||
</div>
|
||||
<MetadataError error={row.meta?.error} t={t} />
|
||||
<RuntimeError error={row.diagnostic} t={t} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -403,7 +409,7 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
|
||||
|
||||
/** The status one card carries: running, off, or a problem the Host reported. */
|
||||
function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' {
|
||||
if (pkg.error !== undefined) return 'problem'
|
||||
if (pkg.error !== undefined || (pkg.enabled && pkg.rows.some(row => row.phase === 'failed'))) return 'problem'
|
||||
return pkg.enabled ? 'running' : 'disabled'
|
||||
}
|
||||
|
||||
@@ -664,6 +670,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {
|
||||
<p className={css.detailDesc}>{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
|
||||
</div>
|
||||
<MetadataError error={row.meta?.error} t={t} />
|
||||
<RuntimeError error={row.diagnostic} t={t} />
|
||||
<div className={css.detailSections} data-plugin-config>
|
||||
{renderSlot('plugins.row.config', { view: 'page', form }, { entryKey: key })}
|
||||
{renderSlot('plugins.detail.section', { subject })}
|
||||
@@ -1571,6 +1578,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
? {}
|
||||
: { role: 'tabpanel', id: `${tabsId}-${INSTALLED_TAB}-panel`, 'aria-labelledby': `${tabsId}-${INSTALLED_TAB}` }
|
||||
const activated = listed.find(pkg => pkg.name === activation && pkg.enabled && !state.busy.includes(pkg.name))
|
||||
const setBundleEnabled = (name: string, enabled: boolean): void => {
|
||||
setActivation(enabled ? name : null)
|
||||
props.setEnabled(name, enabled)
|
||||
}
|
||||
const setRowEnabled = (row: PackageRow, enabled: boolean): void => {
|
||||
/* v8 ignore next -- a row without a live entry has its switch disabled */
|
||||
if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled)
|
||||
@@ -1592,7 +1603,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
highlighted={state.highlight === pkg.name}
|
||||
newer={versionUpdate(state.versions[pkg.name])}
|
||||
onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name, ...showsMoreList ? { from: 'more' } : {} }) }}
|
||||
onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }}
|
||||
onSetEnabled={(enabled) => { setBundleEnabled(pkg.name, enabled) }}
|
||||
/>
|
||||
)
|
||||
const basicCards = ledger.items.map(item => (
|
||||
@@ -1763,7 +1774,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
if (returnTab !== undefined) setTab(returnTab)
|
||||
setView({ kind: backToMore ? 'more' : 'list' })
|
||||
}}
|
||||
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
|
||||
onSetEnabled={(enabled) => { setBundleEnabled(openPkg.name, enabled) }}
|
||||
onUninstall={() => { props.uninstall(openPkg.name) }}
|
||||
onUpdate={() => { props.update(openPkg.name) }}
|
||||
onSetRowEnabled={setRowEnabled}
|
||||
@@ -1808,8 +1819,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
|
||||
{installedCards && activated !== undefined && !state.install.open
|
||||
{onInstalled && activated !== undefined && !state.install.open
|
||||
? renderSlot('plugins.bundle.activation', {
|
||||
packageName: activated.name,
|
||||
onDismiss: () => { setActivation(null) },
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface PackageRow {
|
||||
readonly enabled: boolean
|
||||
/** The entry's fiber phase, null without a live fiber. */
|
||||
readonly phase: PluginInfo['fiberPhase']
|
||||
/** Current startup failure projected by the Host inventory. */
|
||||
readonly diagnostic?: string
|
||||
/** Why the Host refuses to switch the row, when it does. */
|
||||
readonly readOnlyReason?: ReadOnlyReason
|
||||
}
|
||||
@@ -547,6 +549,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
|
||||
moduleName: row.moduleName,
|
||||
enabled: live?.enabled ?? false,
|
||||
phase: live?.fiberPhase ?? null,
|
||||
...live?.diagnostic === undefined ? {} : { diagnostic: live.diagnostic },
|
||||
...row.meta === undefined ? {} : { meta: row.meta },
|
||||
...row.entryId === undefined ? {} : { entryId: row.entryId },
|
||||
...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason },
|
||||
|
||||
@@ -837,6 +837,27 @@ describe('PluginManagerPage', () => {
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true)
|
||||
})
|
||||
|
||||
it('keeps the persisted selection while marking a failed component as a problem', () => {
|
||||
renderTab({ packages: [pkg({ name: 'cua', enabled: true, rows: [row({ phase: 'failed', diagnostic: 'native preparation unavailable' })] })] })
|
||||
expect(document.querySelector('[data-plugin-package="cua"]')?.getAttribute('data-plugin-status')).toBe('problem')
|
||||
expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'cua') }).getAttribute('aria-checked')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'cua') }))
|
||||
expect(screen.getByText(`${en.reasonLabel}: native preparation unavailable`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Computer Use permission guidance on the experimental plugins page', () => {
|
||||
const description = 'Review application access before use.'
|
||||
const { actions } = renderTab({ packages: [pkg({
|
||||
name: '@deepseek-ai/dsh-cua-bundle', enabled: false, optional: true, installed: false,
|
||||
meta: { title: 'Computer Use', description },
|
||||
})] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.moreAction }))
|
||||
expect(screen.getByText(en.statusBeta)).toBeTruthy()
|
||||
expect(screen.getByText(description)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'Computer Use') }))
|
||||
expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-cua-bundle', true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['@deepseek-ai/dsh-experimental-agent-team-profile', true],
|
||||
['@deepseek-ai/dsh-experimental-auto-review', true],
|
||||
@@ -1587,7 +1608,7 @@ describe('PluginManagerPage', () => {
|
||||
fireEvent.click(toggle('row-2'))
|
||||
expect(actions.setRowEnabled).toHaveBeenCalledTimes(1)
|
||||
expect(toggle('include:row-5')).toHaveProperty('disabled', true)
|
||||
expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy()
|
||||
expect(within(detail.querySelector<HTMLElement>('[data-plugin-row="include:row-3"]')!).getByText(en.rowPhaseFailed)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy()
|
||||
expect(within(detail).getByText(en.rowPhaseUnloading)).toBeTruthy()
|
||||
expect(document.querySelector('[data-plugin-row="include:row-3"]')?.getAttribute('data-state')).toBe('failed')
|
||||
@@ -2569,7 +2590,6 @@ it('supplies the accepted entry values and atomic mutation action to a custom pl
|
||||
expect(mutate).toHaveBeenCalledWith([{ op: 'set', path: ['count'], value: 3 }], 7)
|
||||
})
|
||||
|
||||
|
||||
describe('on-demand Official bundles', () => {
|
||||
const name = '@deepseek-ai/dsh-subagent-codex'
|
||||
const title = 'Codex subagent'
|
||||
@@ -2677,6 +2697,28 @@ describe('on-demand Official bundles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the same activation guidance from a package detail switch', () => {
|
||||
const name = 'dsh-better-sidebar'
|
||||
const { set, actions } = renderTab({ packages: [pkg({ enabled: false })] }, { bundles: new Set([name]) }, {
|
||||
[`plugins.bundle.activation:${name}`]: (_view, owner) => <button onClick={(owner as PluginActivationOwnerProps).onDismiss}>Finish setup</button>,
|
||||
[`plugins.bundle.config:${name}`]: () => <div>Bundle setup</div>,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
|
||||
expect(screen.getByText('Bundle setup')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(actions.setEnabled).toHaveBeenCalledWith(name, true)
|
||||
set({ packages: [pkg()], busy: [name] })
|
||||
expect(screen.queryByText('Finish setup')).toBeNull()
|
||||
set({ busy: [] })
|
||||
fireEvent.click(screen.getByText('Finish setup'))
|
||||
expect(screen.queryByText('Finish setup')).toBeNull()
|
||||
expect(screen.getByText('Bundle setup')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
set({ packages: [pkg({ enabled: false })] })
|
||||
expect(screen.queryByText('Finish setup')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
it('keeps bundle configuration mounted while its operation is pending', () => {
|
||||
const name = 'dsh-better-sidebar'
|
||||
const { set } = renderTab({ packages: [pkg()] }, { bundles: new Set([name]) }, {
|
||||
|
||||
@@ -120,6 +120,13 @@ it('hands a custom page the shared configuration form of its entry', () => {
|
||||
})
|
||||
|
||||
describe('packageView', () => {
|
||||
it('carries the current inventory failure into a component row', () => {
|
||||
const plugins = PLUGINS.map(plugin => plugin.entryId === ROW_ENTRY
|
||||
? { ...plugin, fiberPhase: 'failed' as const, diagnostic: 'native preparation unavailable' } : plugin)
|
||||
expect(packageView(BUNDLE, plugins).rows[0]?.diagnostic).toBe('native preparation unavailable')
|
||||
expect(packageView(BUNDLE, PLUGINS).rows[0]?.diagnostic).toBeUndefined()
|
||||
})
|
||||
|
||||
it('joins a bundle with the entries its rows run as', () => {
|
||||
expect(packageView(BUNDLE, PLUGINS)).toEqual({
|
||||
name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0',
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: b563e4ec4727d0de
|
||||
zh: d0228da978238e05
|
||||
/deepseek-ai-dsh-cua-bundle/use-this-package:
|
||||
en: 1f9182a05ea238a8
|
||||
zh: 0d8e2b0ef4d28e1a
|
||||
en: dc35841a42753d00
|
||||
zh: 3c8a03a2ecbec26f
|
||||
/deepseek-ai-dsh-cua-bundle/understand-the-implementation:
|
||||
en: b1011db52cfc17cd
|
||||
zh: 6fa8798fedc5c0b6
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 417c40acd93df532
|
||||
zh: 69cc5cf7140c75a9
|
||||
/deepseek-ai-dsh-cua-bundle/known-limitations-and-deferred-work:
|
||||
en: 41f8c54d76d92c95
|
||||
zh: 44c9662e437f4cbe
|
||||
en: 5586cb62bb16d601
|
||||
zh: 8b06372c77d0cd48
|
||||
/deepseek-ai-dsh-cua-bundle/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0a1253c6f7b5e8b9
|
||||
zh: ce96645b3e75874e
|
||||
@@ -33,15 +33,15 @@ pnpm dsh web
|
||||
|
||||
For the source Desktop launcher and its separate development profile, follow [Desktop development](../../../apps/desktop/README.md). Enable Computer Use in each profile that needs it.
|
||||
|
||||
The switch saves bundle selection in the profile’s `package.json`. Computer Use is installed only in PTC sessions. Enabling through Add plugin or a visible card offers setup guidance when system permissions need attention; enabling from its detail page displays the permission controls directly. The detail page offers **Set to PTC** for the new-session default; existing sessions keep their preset. The switch leaves PTC execution mode and other settings unchanged. The detail page’s **Use persistent mode** action separately enables in-memory state across PTC calls; its effects and reload behavior are described in the [permission page](../cua/README.md). Native preparation runs when the provider loads and reuses the source-hashed executable when available.
|
||||
The switch saves bundle selection in the profile’s `package.json`. Computer Use is installed only in PTC sessions. Enabling through Add plugin, a visible card, or its detail page offers setup guidance when system permissions need attention. The detail page offers **Set to PTC** for the new-session default; existing sessions keep their preset. The switch leaves PTC execution mode and other settings unchanged. The detail page’s **Use persistent mode** action separately enables in-memory state across PTC calls; its effects and reload behavior are described in the [permission page](../cua/README.md). Native preparation runs when the provider loads and reuses the source-hashed executable when available.
|
||||
|
||||
Disabling unloads the provider and withdraws CUA tools from its Agent scopes. Runtime configuration, the new-session default and saved grants remain. Session-only application decisions end with provider disposal.
|
||||
Disabling unloads the provider and withdraws CUA tools from its Agent scopes. Runtime configuration, the new-session default and saved grants remain.
|
||||
|
||||
Computer Use is available only on a macOS DSH Host. The native provider is an optional dependency installed only on macOS; shared CUA and Client packages remain in the installation. Other Hosts hide the bundle from default plugin lists and model recommendations and refuse enablement before saving settings. Explicit profile dependencies and saved selections remain visible for management. An already selected bundle is skipped at startup with a diagnostic and remains available for deselection; saved user settings remain. A browser on another operating system can connect to a supported Mac Host.
|
||||
|
||||
If preparation fails, the saved selection remains and CUA operations stay unavailable. Read the Host startup diagnostic, correct its cause, then restart DSH.
|
||||
If preparation fails, the saved selection remains and CUA operations stay unavailable. Refresh or reopen the plugin page to read the component diagnostic, correct its cause, then restart DSH.
|
||||
|
||||
Explicit activation runs a read-only permission check, which may start a helper without opening a system dialog. Open the [permission page](../cua/README.md) for system requests, settings navigation and automatic refresh. Tasks ask separately before accessing an application. Saved application decisions use the existing profile configuration; session decisions remain in memory across turns and connections until DSH restarts or Computer Use is disabled. Revoke access on the same page.
|
||||
Explicit activation runs a read-only permission check, which may start a helper without opening a system dialog. Open the [permission page](../cua/README.md) for system requests, settings navigation and automatic refresh. Tasks ask separately before accessing an application. Saved application decisions use profile-scoped domain storage. Conversation decisions survive turns, connections, DSH restarts and Computer Use disablement without applying to another conversation. Revoke access on the same page.
|
||||
|
||||
-----
|
||||
|
||||
@@ -86,7 +86,7 @@ CUA activation and removal change the PTC SDK and system guidance; subsequent re
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Source Web and Desktop share permission controls on macOS. Packaged Desktop helper distribution, signing, and permission attribution are deferred.
|
||||
- PTC execution is shared by the profile and is configured independently. A Host without HMR applies saved changes at restart. Saving or revoking application grants while another HMR transaction unloads the provider can block; queued-write cancellation is a separate pending lifecycle fix.
|
||||
- PTC execution is shared by the profile and is configured independently. A Host without HMR applies saved changes at restart.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -33,15 +33,15 @@ pnpm dsh web
|
||||
|
||||
源码 Desktop 的启动方式和独立开发配置档见[桌面开发说明](../../../apps/desktop/README.zh.md)。需要使用 Computer Use 的配置档分别开启该功能。
|
||||
|
||||
开关将组合包选择保存到 profile 的 `package.json`。Computer Use 仅安装在 PTC 会话中。通过「添加插件」或可见卡片启用时,系统权限未就绪会显示设置引导;从详情页启用后直接显示权限控件。详情页提供“设为 PTC”按钮来修改新会话默认模式,已有会话保留原 preset。开关保持 PTC 执行方式和其他设置不变。详情页的“设为持久化”按钮单独开启 PTC 跨调用的内存状态保留,作用范围和重载行为见[权限页面](../cua/README.zh.md)。提供方加载时准备本机组件,已有与源码摘要匹配的可执行文件时直接复用。
|
||||
开关将组合包选择保存到 profile 的 `package.json`。Computer Use 仅安装在 PTC 会话中。通过「添加插件」、可见卡片或详情页启用时,系统权限未就绪会显示设置引导。详情页提供“设为 PTC”按钮来修改新会话默认模式,已有会话保留原 preset。开关保持 PTC 执行方式和其他设置不变。详情页的“设为持久化”按钮单独开启 PTC 跨调用的内存状态保留,作用范围和重载行为见[权限页面](../cua/README.zh.md)。提供方加载时准备本机组件,已有与源码摘要匹配的可执行文件时直接复用。
|
||||
|
||||
关闭会卸载提供方,并从其 Agent 作用域中撤回 CUA 工具。执行器配置、新会话默认值和已保存的授权保留。会话级应用授权随提供方卸载而失效。
|
||||
关闭会卸载提供方,并从其 Agent 作用域中撤回 CUA 工具。执行器配置、新会话默认值和已保存的授权保留。
|
||||
|
||||
Computer Use 仅支持 macOS DSH Host。原生提供方是仅在 macOS 安装的可选依赖,共享 CUA 和 Client 包仍随安装提供。其他 Host 默认不在插件列表和模型推荐中展示此组合包,并在保存设置前拒绝启用。profile 显式安装的依赖和已保存的选择仍保留管理入口。已选中的组合包会在启动时跳过并报告原因,仍可取消选择;已保存的用户设置保留。其他操作系统上的浏览器可以连接受支持的 Mac Host。
|
||||
|
||||
准备失败时,已保存的选择保留,CUA 操作不可用。查看 Host 的启动诊断,修复原因后重启 DSH。
|
||||
准备失败时,已保存的选择保留,CUA 操作不可用。刷新或重新打开插件页查看组件诊断,修复后重启 DSH。
|
||||
|
||||
显式启用会进行只读权限检查,可能启动 helper,但不会打开系统授权弹窗。[权限页面](../cua/README.zh.md) 提供系统申请入口、设置跳转和自动检测。任务访问应用前会单独询问用户。长期应用授权保存在现有 profile 配置中,会话授权保存在内存中,跨 turn 和连接复用,重启 DSH 或关闭 Computer Use 后失效。可以在同一页面撤销授权。
|
||||
显式启用会进行只读权限检查,可能启动 helper,但不会打开系统授权弹窗。[权限页面](../cua/README.zh.md) 提供系统申请入口、设置跳转和自动检测。任务访问应用前会单独询问用户。应用授权保存在按 profile 隔离的存储域中。会话授权跨 turn、连接、DSH 重启和 Computer Use 启停保留,只在原对话中生效。可以在同一页面撤销授权。
|
||||
|
||||
-----
|
||||
|
||||
@@ -86,7 +86,7 @@ CUA 生效和卸载会改变 PTC SDK 与系统指引,后续请求使用更新
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 源码 Web 与 Desktop 共用 macOS 权限控件。桌面安装包的 helper 分发、签名和权限归属留待后续处理。
|
||||
- PTC 执行方式由 profile 共享,需单独配置。未启用 HMR 的 Host 在重启时应用已保存的变更。保存或撤销应用授权与另一次卸载提供方的 HMR 同时发生时可能阻塞;排队写入取消另行修复。
|
||||
- PTC 执行方式由 profile 共享,需单独配置。未启用 HMR 的 Host 在重启时应用已保存的变更。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 867d9ec544719acf
|
||||
zh: 92348599582dc33a
|
||||
/deepseek-ai-dsh-experimental-cua-macos/use-this-package:
|
||||
en: 1a81b1ce425fa6dd
|
||||
zh: c6ee0efef5fc3ed0
|
||||
en: dd5af44ffb89f90e
|
||||
zh: 97b11cd33340eb93
|
||||
/deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment:
|
||||
en: 786b8fd630b11e5f
|
||||
zh: 55143f15486afce8
|
||||
|
||||
@@ -45,7 +45,7 @@ The dump command checks profile composition without a model request. For a real
|
||||
| Field | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `agentPreset` | `ptc` | Register tools and guidance only for Agents bound to this preset; `false` registers globally. |
|
||||
| `allowedApps` | `[]` | Live profile-local application consent, keyed by executable path and verified signing requirement. |
|
||||
| `allowedApps` | `[]` | Legacy signed grants, imported once into profile-scoped storage. |
|
||||
| `helperPath` | package-owned executable | Optional absolute native executable override. |
|
||||
| `buildTimeoutMs` | `300000` | Maximum native preparation time. |
|
||||
| `timeoutMs` | `30000` | Per-operation deadline; cancellation can leave partial input. |
|
||||
@@ -67,7 +67,7 @@ Keypad names (`KP_0` through `KP_9`, `KP_Enter`, and keypad operators) retain th
|
||||
|
||||
Agent disposal and plugin unload cancel and await the complete tool call, including image admission and attachment storage. An image write already in progress must settle before disposal returns; a canceled call cannot return a successful screenshot.
|
||||
|
||||
Application consent is enforced before AX reads, window-title reads, screenshots, activation, and input through the CUA tool. The first selection asks the human for session access, denial, or saved access when the application has a verifiable signature. Session access is kept in memory by session and application identity, survives agent and helper replacement, and expires when DSH restarts or Computer Use is disabled. Signed application identities combine the canonical executable path and designated code-signing requirement; unverifiable applications remain bound to their process instance. Denial prevents repeated prompts in that session. Revocation reads and removes saved access in the same queue as grant creation, invalidates each affected Agent connection, and awaits its native cleanup without replaying input. Every app handle on those Agent connections becomes unusable; other applications need fresh selection, but their session and saved grants remain reusable. See the [application consent decision](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md). These checks govern CUA calls; persistent PTC and separately authorized shell tools are not an operating-system sandbox.
|
||||
Application consent is enforced before AX reads, window-title reads, screenshots, activation, and input through the CUA tool. The first selection asks the human for session access, denial, or saved access when the application has a verifiable signature. Session access is stored by conversation and application identity and survives DSH restart and Computer Use disablement. Other conversations require their own consent. Signed application identities combine the canonical executable path and designated code-signing requirement; unverifiable applications remain bound to their process instance. Denial prevents repeated prompts in that session. Revocation reads and removes saved access in the same queue as grant creation, invalidates each affected Agent connection, and awaits its native cleanup without replaying input. Every app handle on those Agent connections becomes unusable; other applications need fresh selection, but their session and saved grants remain reusable. See the [application consent decision](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.md). These checks govern CUA calls; persistent PTC and separately authorized shell tools are not an operating-system sandbox.
|
||||
|
||||
Raw `getState` exposes validated state fields and the complete text for the requested disclosure options. The native helper also returns structured element diagnostics for native checks; the tool projection omits that duplicate array from model text and structured tool values.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis.
|
||||
| 字段 | 默认值 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `agentPreset` | `ptc` | 仅为绑定该 preset 的 Agent 注册工具和指引;`false` 表示全局注册。 |
|
||||
| `allowedApps` | `[]` | 当前配置档的动态应用授权,绑定可执行文件路径和已验证的代码签名要求。 |
|
||||
| `allowedApps` | `[]` | 旧的已签名应用许可,只导入 profile 存储一次。 |
|
||||
| `helperPath` | 包内可执行文件 | 可选的原生可执行文件绝对路径覆盖。 |
|
||||
| `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 |
|
||||
| `timeoutMs` | `30000` | 单次操作截止时间,取消后可能已发生部分输入。 |
|
||||
@@ -67,7 +67,7 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis.
|
||||
|
||||
Agent 销毁和插件卸载会取消并等待完整工具调用,包括图像接入检查和附件存储。已经开始的图像写入必须结束后,销毁才会返回;已取消的调用不会返回成功截图。
|
||||
|
||||
CUA 工具在读取 AX、窗口标题、截图、激活和输入前检查应用授权。首次选择应用时询问用户,可选择本会话允许、拒绝;签名可验证的应用还可保存长期授权。会话许可按会话和应用身份保存在内存中,Agent 或 helper 重建后仍可复用,重启 DSH 或关闭 Computer Use 后失效。已签名应用的身份由规范化可执行文件路径和代码签名指定要求组成;签名不可验证的应用仍绑定进程实例。拒绝后,同一会话不会反复询问。撤销与长期许可的保存共用队列,在队列内读取并移除许可,然后使相关 Agent 连接失效并等待原生清理完成,不自动重放输入。同一 Agent 连接中的其他应用也需要重新选择,其他应用的会话许可和长期许可仍可复用。设计理由见[应用授权决策](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md)。这些检查的范围是 CUA 调用。persistent PTC 与已授权的 shell 仍按各自执行权限运行。
|
||||
CUA 工具在读取 AX、窗口标题、截图、激活和输入前检查应用授权。首次选择应用时询问用户,可选择本会话允许、拒绝;签名可验证的应用还可保存长期授权。会话许可按对话和应用身份持久化,重启 DSH 或关闭 Computer Use 后仍可复用,其他对话需要单独授权。已签名应用的身份由规范化可执行文件路径和代码签名指定要求组成;签名不可验证的应用仍绑定进程实例。拒绝后,同一会话不会反复询问。撤销与长期许可的保存共用队列,在队列内读取并移除许可,然后使相关 Agent 连接失效并等待原生清理完成,不自动重放输入。同一 Agent 连接中的其他应用也需要重新选择,其他应用的会话许可和长期许可仍可复用。设计理由见[应用授权决策](../../../.agents/notes/implemented/architecture/2026-09-26-cua-application-consent.zh.md)。这些检查的范围是 CUA 调用。persistent PTC 与已授权的 shell 仍按各自执行权限运行。
|
||||
|
||||
原始 `getState` 操作返回经过校验的状态字段,以及按本次披露选项生成的完整文本。原生 helper 还返回供原生检查使用的结构化元素诊断数组;工具投影会从模型文本和结构化工具值中移除这份重复数组。
|
||||
|
||||
|
||||
@@ -85,7 +85,10 @@
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:*",
|
||||
"@deepseek-ai/dsh-config-editor": "workspace:*",
|
||||
"@deepseek-ai/dsh-hmr": "workspace:*",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:~"
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
|
||||
"@deepseek-ai/dsh-storage": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build:native": "swift build --package-path native --configuration release"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Native helper replacement is synchronized with Agent and provider disposal. */
|
||||
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -11,6 +11,9 @@ import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { initProfile, mountRootInclude, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
|
||||
import ConfigEditor from '@deepseek-ai/dsh-config-editor'
|
||||
import Settings from '@deepseek-ai/dsh-settings'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import * as StorageJson from '@deepseek-ai/dsh-storage-json'
|
||||
import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
|
||||
import Hmr from '@deepseek-ai/dsh-hmr'
|
||||
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use'
|
||||
@@ -110,7 +113,7 @@ async function selectApplication(f: Awaited<ReturnType<typeof setup>>) {
|
||||
it('rejects a relative helper path before publishing computer-use operations', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await expect(NativeProvider.apply(ctx, { ...config, allowedApps: { get: () => [] }, helperPath: 'relative-helper' })).rejects.toThrow('helperPath must be absolute')
|
||||
await expect(NativeProvider.apply(ctx, { ...config, allowedApps: [], helperPath: 'relative-helper' })).rejects.toThrow('helperPath must be absolute')
|
||||
})
|
||||
|
||||
it.each(['Allow for this session', 'Deny'])('obtains %s before a stopped application can launch', async (choice) => {
|
||||
@@ -577,7 +580,7 @@ it('prepares the selected native component before registering CUA and creates no
|
||||
const f = await setup()
|
||||
await f.provider.dispose()
|
||||
native.prepare.mockReset().mockResolvedValue(resolve('prepared-helper'))
|
||||
await NativeProvider.apply(f.ctx, { ...automaticConfig, allowedApps: { get: () => [] } })
|
||||
await NativeProvider.apply(f.ctx, { ...automaticConfig, allowedApps: [] })
|
||||
expect(native.prepare).toHaveBeenCalledTimes(1)
|
||||
expect(native.instances).toHaveLength(0)
|
||||
await f.execute()
|
||||
@@ -626,7 +629,7 @@ it('waits for canceled preparation and never publishes its late success', async
|
||||
const ready = Promise.withResolvers<string>()
|
||||
onTestFinished(() => { ready.resolve(resolve('late-helper')) })
|
||||
native.prepare.mockReset().mockReturnValue(ready.promise)
|
||||
const starting = Promise.resolve(NativeProvider.apply(f.ctx, { ...automaticConfig, allowedApps: { get: () => [] } }))
|
||||
const starting = Promise.resolve(NativeProvider.apply(f.ctx, { ...automaticConfig, allowedApps: [] }))
|
||||
const failed = expect(starting).rejects.toThrow()
|
||||
const signal = native.prepare.mock.calls[0]![0].signal
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
@@ -649,11 +652,12 @@ it('rejects an unsupported native host before starting preparation', async () =>
|
||||
await f.provider.dispose()
|
||||
native.architecture.mockImplementationOnce(() => { throw new Error('unsupported host') })
|
||||
native.prepare.mockReset()
|
||||
await expect(NativeProvider.apply(f.ctx, { ...automaticConfig, allowedApps: { get: () => [] } })).rejects.toThrow('unsupported host')
|
||||
await expect(NativeProvider.apply(f.ctx, { ...automaticConfig, allowedApps: [] })).rejects.toThrow('unsupported host')
|
||||
expect(native.prepare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores signed application consent in the current profile and refuses unavailable settings', async () => {
|
||||
|
||||
async function permissionProfile(allowedApps: Array<{ id: string; name: string; executablePath: string }> = []) {
|
||||
const f = await setup()
|
||||
await f.provider.dispose()
|
||||
const home = await mkdtemp(join(tmpdir(), 'cua-grant-profile-')), dir = join(home, 'profile')
|
||||
@@ -664,7 +668,7 @@ it('stores signed application consent in the current profile and refuses unavail
|
||||
await writeFile(join(home, 'package.json'), '{"name":"test-installation"}\n')
|
||||
await writeFile(join(bundle, 'package.json'), JSON.stringify({ name: 'permission-fixture-bundle', version: '1.0.0',
|
||||
dsh: { bundle: { patch: 'cordis.patch.yml' } } }))
|
||||
await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'cua', name: 'cordis:cua', config }] }]))
|
||||
await writeFile(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'cua', name: 'cordis:cua', config: { ...config, allowedApps } }] }]))
|
||||
await writeFile(join(dir, 'cordis.yml'), '[]\n')
|
||||
const profile: ProfileContext = { name: 'test', startedBundles: ['permission-fixture-bundle'], dir,
|
||||
patchPath: join(dir, 'cordis.patch.yml'), installAnchor: join(home, 'package.json'), cwd: home, home,
|
||||
@@ -675,35 +679,56 @@ it('stores signed application consent in the current profile and refuses unavail
|
||||
await f.ctx.plugin(Loader)
|
||||
f.ctx.loader.builtins.cua = NativeProvider
|
||||
await f.ctx.plugin(ConfigEditor)
|
||||
await f.ctx.plugin(Storage)
|
||||
await f.ctx.plugin(StorageJson, { root: join(home, 'storages') })
|
||||
await f.ctx.plugin(StorageDomain, { backend: 'json' })
|
||||
await mountRootInclude(f.ctx, join(dir, 'cordis.yml'), readProfilePatches('test', profile))
|
||||
await f.ctx.loader.await()
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
const answer = vi.fn(async () => ({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] }))
|
||||
f.ctx.on('user-questions/request', answer)
|
||||
native.request.mockResolvedValue({ appHandle: 'task-app', pid: 17, name: 'Fixture',
|
||||
authorization: { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true } })
|
||||
expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false)
|
||||
native.request.mockImplementation(async (method: string) => method === 'applicationIcons' ? {}
|
||||
: { accessibility: true, screenRecording: true, helper: { pid: 2 }, parent: { pid: 1 } })
|
||||
expect((await f.ctx.cuaAccess.applications()).persistentAvailable).toBe(true)
|
||||
await f.ctx.plugin(Settings)
|
||||
await f.ctx.plugin(Timer)
|
||||
await f.ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await f.ctx.hmr.runExclusive(async () => {})
|
||||
expect(f.ctx.settings.describe().find(row => row.ns === 'cua')?.autoGenerate).toBe(false)
|
||||
answer.mockResolvedValue({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] })
|
||||
const authorization = { id: 'b'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true }
|
||||
return { ...f, profile }
|
||||
}
|
||||
|
||||
it('stores revocation without entering the HMR configuration queue', async () => {
|
||||
const grant = { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app' }
|
||||
const f = await permissionProfile([grant])
|
||||
const before = await readFile(f.profile.patchPath, 'utf8')
|
||||
const acquired = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => { release.resolve(undefined) })
|
||||
const blocked = f.ctx.hmr.runExclusive(async () => { acquired.resolve(undefined); await release.promise })
|
||||
await acquired.promise
|
||||
await f.ctx.cuaAccess.revoke(grant.id as import('@deepseek-ai/dsh-cua/types').CuaApplicationId)
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toEqual([])
|
||||
expect(await readFile(f.profile.patchPath, 'utf8')).toBe(before)
|
||||
release.resolve(undefined)
|
||||
await blocked
|
||||
})
|
||||
|
||||
it('stores signed application consent separately from configuration', async () => {
|
||||
const f = await permissionProfile(), { profile } = f
|
||||
expect(f.ctx.settings.describe().find(row => row.ns === 'cua')).toBeUndefined()
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
f.ctx.on('user-questions/request', async () => ({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] }))
|
||||
const authorization = { id: 'a'.repeat(64), name: 'Fixture', executablePath: '/fixture/app', persistable: true }
|
||||
native.request.mockResolvedValue({ appHandle: 'signed-app', pid: 17, name: 'Fixture', authorization })
|
||||
expect((await f.execute('getApp', { selector: 'Fixture' })).isError).toBe(false)
|
||||
expect(await readFile(profile.patchPath, 'utf8')).toContain(authorization.id)
|
||||
const settings = f.ctx.settings.describe().find(row => row.ns === 'cua')!
|
||||
await f.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }], settings.revision)
|
||||
expect(await readFile(profile.patchPath, 'utf8')).toContain('allowedApps: []')
|
||||
const revoked = await f.execute('getState', { appHandle: 'signed-app' })
|
||||
expect(revoked.isError).toBe(true)
|
||||
expect(JSON.stringify(revoked.content)).toContain('APP_ACCESS_REVOKED')
|
||||
vi.spyOn(f.ctx.settings, 'describe').mockReturnValue([])
|
||||
native.request.mockResolvedValue({ appHandle: 'different-app', pid: 18, name: 'Other fixture', authorization: { ...authorization, id: 'c'.repeat(64) } })
|
||||
const unavailable = await f.execute('getApp', { selector: 'Fixture' })
|
||||
expect(unavailable.isError).toBe(true)
|
||||
expect(JSON.stringify(unavailable.content)).toContain('APP_ACCESS_DENIED')
|
||||
expect(await readFile(profile.patchPath, 'utf8')).not.toContain(authorization.id)
|
||||
const files = await readdir(join(profile.home, 'storages'))
|
||||
const stored = files.find(file => file.startsWith('cua_permissions_'))!
|
||||
expect(await readFile(join(profile.home, 'storages', stored), 'utf8')).toContain(authorization.id)
|
||||
native.request.mockImplementation(async (method: string) => method === 'applicationIcons' ? {}
|
||||
: { accessibility: true, screenRecording: true, helper: { pid: 2 }, parent: { pid: 1 } })
|
||||
const permission = await f.ctx.cuaAccess.applications()
|
||||
expect(permission.allowedApps).toHaveLength(1)
|
||||
await f.ctx.cuaAccess.revoke(permission.allowedApps[0]!.id)
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toEqual([])
|
||||
await f.ctx.cuaAccess.retryApproval(permission.allowedApps[0]!.id)
|
||||
|
||||
})
|
||||
|
||||
it('rejects an application handle before any helper or consent has been acquired', async () => {
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
en: 5db4d4fea25493b9
|
||||
zh: 65ab64fc24eb0d09
|
||||
/deepseek-ai-dsh-cua/use-this-package:
|
||||
en: 4b4000d5eafc9256
|
||||
zh: 4eeae251ec7c8ec4
|
||||
en: db09ea66bf04180c
|
||||
zh: 9b3a573e83443482
|
||||
/deepseek-ai-dsh-cua/use-this-package/permission-controls:
|
||||
en: 7315ac8d68cf5d02
|
||||
zh: 037407055d9f34a0
|
||||
en: 6706beb084a70fe4
|
||||
zh: b188bf09b115ce84
|
||||
/deepseek-ai-dsh-cua/understand-the-implementation:
|
||||
en: a32743fc5b545ab3
|
||||
zh: e8842c589966202b
|
||||
en: be463bdd095cc2a9
|
||||
zh: 23248be7a2f0b1db
|
||||
/deepseek-ai-dsh-cua/further-exploration:
|
||||
en: b8b0aece886fadaa
|
||||
zh: ecd8c88d601f2179
|
||||
en: 166e3c1f9a67f753
|
||||
zh: 698c5136c5350052
|
||||
/deepseek-ai-dsh-cua/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
@@ -42,8 +42,8 @@
|
||||
en: 2d5df52dafa1d940
|
||||
zh: 06e815759b9150c3
|
||||
/deepseek-ai-dsh-cua/known-limitations-and-deferred-work:
|
||||
en: ae98f1b2718b3b94
|
||||
zh: e6bf4e96ae08e669
|
||||
en: a4b9d44dec85b0d1
|
||||
zh: 3a393e508397ecc2
|
||||
/deepseek-ai-dsh-cua/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0a1253c6f7b5e8b9
|
||||
zh: 9f56528562c7c914
|
||||
@@ -35,7 +35,7 @@ Observation methods emit and return their result. Use `void await app.getAXState
|
||||
|
||||
`tools.cua` returns operation values directly. Screenshot values retain the encoded image Base64 and add a durable `attachment` reference for the tool renderer; model content contains coordinate metadata and one image, without the encoded bytes. Before capture, the tool requires an attachment store and a resolved model route declaring image input. Admission and storage failures reject the call. The object bridge preserves the screenshot bytes and validated metadata without uploading or emitting the same image again.
|
||||
|
||||
Application consent precedes launch, observation, capture, and input. Session access is kept in memory by session and application identity, survives agent and helper replacement, and expires when DSH restarts or Computer Use is disabled. Saved grants require platform-verified executable identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect.
|
||||
Application consent precedes launch, observation, capture, and input. Session access is stored by conversation and application identity and survives DSH restart and Computer Use disablement. Other conversations require their own consent. Saved grants require platform-verified executable identity. Refusal and revocation close affected Agent connections, so their old objects require fresh selection. No input is replayed after cancellation, timeout, or reconnect.
|
||||
|
||||
Actions return `dispatched:true, verified:false`; read fresh state to verify the result. Coordinate actions default to pixels in the latest returned screenshot; `relative: true` accepts integers from 0 to 999 after a successful window observation. Values are divided by 1000 and scaled to the window dimensions, then clamped inside the window. The Host resolves attachment normalization and the selected model route’s image projection before returning the screenshot, then converts input to native window points. Resizing invalidates the screenshot mapping; `SCREENSHOT_REQUIRED` requires a new screenshot for absolute coordinates. Resizing rejects old coordinates with `WINDOW_CHANGED`; movement alone preserves window-local coordinates.
|
||||
|
||||
@@ -43,11 +43,13 @@ Actions return `dispatched:true, verified:false`; read fresh state to verify the
|
||||
|
||||
### Permission controls
|
||||
|
||||
On the Computer Use detail page, permission cards, component rows, and source facts use l3 borders, with 32px gaps between sections. Application badges retain their l1 borders.
|
||||
|
||||
The [Computer Use bundle](../cua-bundle/README.md) mounts this page with its native provider. **Authorize computer use** groups the new-session default, PTC execution mode, Accessibility, and Screen Recording inside one border, with dividers between the rows. Each system-permission row shows the system-reported responsible application’s `.app` bundle filename and icon together in a bordered label. **Manage applications computer use can access** places the application lists inside a separate bordered group. Choose **Request permission**, find that application in the macOS permission list, and enable its switch; if macOS names another application, follow that prompt. With no detected source, the page directs the user to the application named by macOS. Each system-permission row has one control: **Request permission** when access is missing, or an outlined **Allowed** button with a settings icon after authorization. Clicking it opens the corresponding privacy pane, where the user can revoke access; hovering shows **Open System Settings**. Unknown status uses the same button with an **Unknown** label; pending initial observations retain a single placeholder. The page refreshes on entry, when the window regains focus, and after permission actions. A failed query offers **Retry**. Follow any macOS quit-and-reopen prompt and restart DSH as needed.
|
||||
|
||||
System-permission operations affect the computer running DSH. When accessing DSH remotely, complete authorization on that computer.
|
||||
|
||||
Enabling from the plugin list shows setup guidance when system permissions need attention, with **Later** and **Open settings** actions. Enabling from the detail page displays the permission controls directly. The detail page provides **Set to PTC** under **Default mode for new sessions**; only this action saves the default. Existing sessions keep their preset. **Use persistent mode** in the **PTC execution mode** row separately saves `ptc-runtime.executionMode: persistent-in-memory`. Switching reloads the shared PTC runtime and may interrupt an active call; subsequent calls retain variables and application objects in memory. Already-persistent runtimes show a disabled **Persistent mode enabled** button.
|
||||
Enabling from the plugin list or detail page shows setup guidance when system permissions need attention, with **Later** and **Open settings** actions. The detail page provides **Set to PTC** under **Default mode for new sessions**; only this action saves the default. Existing sessions keep their preset. **Use persistent mode** in the **PTC execution mode** row separately saves `ptc-runtime.executionMode: persistent-in-memory`. Switching reloads the shared PTC runtime and may interrupt an active call; subsequent calls retain variables and application objects in memory. Already-persistent runtimes show a disabled **Persistent mode enabled** button.
|
||||
|
||||
The page lists always-allowed applications and refusals from any session. A refusal in one session does not cancel another session’s grant or prevent a new session from asking. Session-only grants remain effective within their sessions and are omitted from this page. **Revoke** removes the selected application's access and cancels affected work. Input already delivered to an application cannot be undone; observe the application before any further action. **Allow asking again** clears that application’s refusal records across all sessions; the next application selection still requires a new decision.
|
||||
|
||||
@@ -61,18 +63,22 @@ The Host marker has no configurable fields; platform providers own native config
|
||||
<details>
|
||||
<summary>Shared execution and platform ownership</summary>
|
||||
|
||||
The [permission store](src/permission-store.ts) uses `storageDomain` with the configured backend. The default JSON backend writes `$DSH_HOME/storages/cua_permissions_<sha256>.json`, where the digest identifies the canonical profile directory. Each authorization check opens fresh state under the profile's file lock and closes its domain afterward; native dispatch uses a separate application lock. Revocation waits for operations already admitted by another process; after it completes, every process rejects old handles. Files on different machines or PID namespaces must not share this lock.
|
||||
|
||||
Legacy `allowedApps` are imported on first store access and never reapplied after migration. The old configuration can be removed after checking the permission page. Grants, denials and the option to ask again are durable; the page still omits conversation-only grants. Standalone compositions without a profile retain only process-local decisions; a profile without domain storage refuses authorization operations.
|
||||
|
||||
The permission Remote also exposes `ptcConfiguration()` and `enablePersistentPtc()` for the detail-page runtime setting. Reads inspect the existing runtime and editable profile entry. Explicit writes use `configEditor.edit()` to preserve other fields and apply normal Loader reload; an already-persistent runtime is left intact. This setting is separate from system and application authorization.
|
||||
|
||||
The provider calls [installCua](src/index.ts) with its prepared helper, connection factory, and guidance. [NativeClient](src/native-client.ts) owns request correlation, byte limits, cancellation acknowledgement, and failure settlement. The platform connector owns startup and connection cleanup. The shared executor retains one connection per Agent and joins in-flight image writes during disposal.
|
||||
|
||||
`agentPreset` defaults to `ptc`, restricting tools and guidance to Agents bound to that preset. The provider uses the existing `agent/created` and `agent-preset/selected` events and Agent-owned scopes, including Agents already present when it loads. Provider unload and Agent disposal release those registrations. Preset definitions may register after the provider; a preset name filters bound Agents and supplies no tools until a matching Agent exists. Set `agentPreset: false` for global registration in standalone compositions without presets. The Host continues to own permissions, native connections and cancellation; preset definitions remain unchanged.
|
||||
|
||||
[The operation schema](src/protocol.ts) is the authority for model parameters and separately declared Host-only fields. Native implementations enforce target identity, window geometry, resource limits, and operating-system access at execution. No invariant companion is published because this library has no separately projected registry or durable state.
|
||||
[The operation schema](src/protocol.ts) is the authority for model parameters and separately declared Host-only fields. Native implementations enforce target identity, window geometry, resource limits, and operating-system access at execution. The permission store owns durable consent separately from the native protocol.
|
||||
|
||||
|
||||
The root Host entry exports `installCua`, `CuaConfig`, and the Typert-discovered `CuaAccess` service; its `apply()` only marks the optional Client contribution. `/client` loads the permission UI through the existing module loader. Separate Host and Client compiler projects keep React, CSS, and browser registrations out of headless imports. `/api` owns the app/window API and its types; `/bridge`, `/native-client`, and `/protocol` retain runtime and platform consumers. `/types` exports only the four named boundary types required by generated Remote declarations. The Client uses package-local types. `/typert` supplies Host reflection and `/remote` supplies generated browser descriptors and codecs; permission authority remains on the Host.
|
||||
|
||||
The optional browser contribution mounts the native provider's permission Remote and reuses the Plugins activation and configuration slots. The four setup rows remain visible while unknown fields use placeholders; the same permission-management guidance remains visible while queries settle and after access is granted, including when the text wraps. The default-mode form, runtime configuration, system checks, application decisions, and application icons fill in as each query completes. Application names and actions do not wait for icons; icon positions retain their space. User actions run in order without waiting for pending observation queries. Disposal waits for both actions and queries to settle before releasing the separately owned Remote mount. Known query errors and read-only settings stay visible while management operations or retries run, with their actions disabled. Query errors stay with the affected section, and activation guidance depends only on system checks. The injection layer observes Plugin Manager operations independently of the page. Bundle and row changes pause all four queries and invalidate in-flight results; settlement refreshes observations even after the page closes. Last known errors remain until a successful read. Disposal discards late results. The controller consumes the generated `ctx.remote.cuaAccess` namespace directly and handles unsuccessful Remote results in its existing query and action paths. It projects queries into the page and setup dialog; reconnects and updates to the Host-reported settings namespace refresh it. Operation notices live in the shell overlay, so closing the page does not remove them. The provider owns permission checks, persisted decisions, and cancellation; the browser owns no authorization state.
|
||||
The optional browser contribution mounts the native provider's permission Remote and reuses the Plugins activation and configuration slots. The four setup rows remain visible while unknown fields use placeholders; the same permission-management guidance remains visible while queries settle and after access is granted, including when the text wraps. The default-mode form, runtime configuration, system checks, application decisions, and application icons fill in as each query completes. Application names and actions do not wait for icons; icon positions retain their space. User actions run in order without waiting for pending observation queries. Disposal waits for both actions and queries to settle before releasing the separately owned Remote mount. Known query errors and read-only settings stay visible while management operations or retries run, with their actions disabled. Query errors stay with the affected section, and activation guidance depends only on system checks. The injection layer observes Plugin Manager operations independently of the page. Bundle and row changes pause all four queries and invalidate in-flight results; settlement refreshes observations even after the page closes. Last known errors remain until a successful read. Disposal discards late results. The controller consumes the generated `ctx.remote.cuaAccess` namespace directly and handles unsuccessful Remote results in its existing query and action paths. It projects queries into the page and setup dialog; reconnects refresh it. Page entry, focus and completed actions read the current permission store. Operation notices live in the shell overlay, so closing the page does not remove them. The provider owns permission checks, persisted decisions, and cancellation; the browser owns no authorization state.
|
||||
|
||||
The detail page reads the effective default (`selectedDefault ?? default`) from the existing `configForms` service. The default button writes only `selectedDefault` through the form's revision-checked settings path. The execution-mode row uses CUA's `ptcConfiguration()` and `enablePersistentPtc()` controls: the Host edits only `ptc-runtime.executionMode` through the existing configuration editor, preserving its ordinary Loader reload lifecycle. Permission checks and runtime state settle independently. Loading, read-only and unavailable settings cannot submit a change; a failed write remains beside the setting for retry.
|
||||
</details>
|
||||
@@ -84,6 +90,7 @@ The detail page reads the effective default (`selectedDefault ?? default`) from
|
||||
|
||||
- [macOS provider](../cua-macos/README.md)
|
||||
- [Object interfaces](src/types.ts)
|
||||
- [Consent storage migration](../../../docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.md)
|
||||
|
||||
-----
|
||||
|
||||
@@ -108,7 +115,7 @@ Results append to history. Retaining objects does not rewrite previous messages;
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- In-memory session decisions have no capacity limit or eviction. They remain until the provider unloads, so a long-running Host retains entries for every session/application pair that requested access.
|
||||
- Conversation decisions have no automatic retention limit or deletion when a conversation is removed. Revocation removes permission, while its generation remains to reject older handles and pending approvals.
|
||||
- Native execution currently uses the macOS provider. Windows support is planned as a separate provider package with a C# helper, retaining the shared app/window API, authorization, snapshot validity, and action-result semantics. Its implementation must document system-specific limitations and distinguish cross-compilation evidence from Windows desktop validation.
|
||||
- Persistent variables do not reserve an application across a sequence of operations. Other Agents and the user can change its state. Hover and observations do not restore the previous pointer position; [continuous interaction](../../../.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md) uses no cross-call ownership.
|
||||
- The package stays in `experimental` until Windows support and platform validation permit promotion to the `computer-use` group.
|
||||
|
||||
@@ -35,7 +35,7 @@ kind: "package-reference"
|
||||
|
||||
`tools.cua` 直接返回操作结果。截图结果保留图片 Base64,并增加供工具渲染使用的持久 `attachment` 引用;模型内容只包含坐标元数据和一张图片,不包含编码字节。截图前要求附件服务可用,并确认当前模型支持图片输入。图片接入或保存失败会拒绝调用。对象桥接保留截图字节和已校验的元数据,不重复上传或输出同一张图片。
|
||||
|
||||
应用授权发生在启动、观察、截图和输入之前。会话许可按会话和应用身份保存在内存中,Agent 或 helper 重建后仍可复用,重启 DSH 或关闭 Computer Use 后失效。长期许可要求平台验证可执行文件身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。
|
||||
应用授权发生在启动、观察、截图和输入之前。会话许可按对话和应用身份持久化,重启 DSH 或关闭 Computer Use 后仍可复用,其他对话需要单独授权。长期许可要求平台验证可执行文件身份。拒绝或撤销会关闭受影响的 Agent 连接,因此旧对象必须重新选择目标。取消、超时或重新连接后均不重放输入。
|
||||
|
||||
动作返回 `dispatched:true, verified:false`;请读取新状态以验证结果。坐标操作默认使用最近返回截图中的像素;成功观察窗口后,传入 `relative: true` 可使用 0~999 的整数坐标。数值除以 1000 后按窗口尺寸缩放,并限制在窗口内。Host 在返回截图前解析附件归一化和当前模型的图片缩放规则,再将输入换算为原生窗口 point。窗口尺寸变化会使截图映射失效,绝对坐标遇到 `SCREENSHOT_REQUIRED` 时需要重新截图。窗口缩放后以 `WINDOW_CHANGED` 拒绝旧坐标;单纯移动窗口不改变窗口内坐标。
|
||||
|
||||
@@ -43,11 +43,13 @@ kind: "package-reference"
|
||||
|
||||
### 权限控件
|
||||
|
||||
Computer Use 详情页的权限卡片、组件列表和来源信息使用 l3 边框,区块间距为 32px。应用名称徽标保留 l1 边框。
|
||||
|
||||
[Computer Use 组合](../cua-bundle/README.zh.md)将本页与原生 provider 一起挂载。“给 Computer Use 授权”把新会话默认模式、PTC 执行方式、辅助功能和屏幕录制放在同一个边框内,四排之间使用分隔线。两条系统权限要求在能识别责任应用时,把该应用的 `.app` 包名称和图标放在同一个标签中。“管理 Computer Use 可访问的应用”使用独立边框包住应用列表。点击“申请权限”,在 macOS 权限列表中找到该应用并开启对应开关;若 macOS 显示其他名称,以系统提示为准。来源无法识别时,页面引导用户查找系统提示的应用。每条系统权限要求只显示一个控件:未授权时为“申请权限”,已授权时为带设置图标的“已允许”描边按钮,点击直接进入对应隐私设置页,由用户撤销权限,悬停提示“打开系统设置”。无法确认状态时使用同样的按钮,文字为“未确认”。首次查询期间保留一个占位。进入详情页、返回窗口或完成授权操作后会自动刷新;查询失败时可点击“重试”。若系统要求退出并重新打开应用,请按提示操作并重新启动 DSH。
|
||||
|
||||
系统权限操作作用于运行 DSH 的电脑。远程访问时,请在该电脑上完成授权。
|
||||
|
||||
从插件列表启用后,系统权限尚未就绪时会显示设置引导,提供“稍后”和“前往设置”。从详情页启用后直接显示权限控件。详情页的“新会话默认模式”提供“设为 PTC”按钮,仅点击此按钮才保存默认值。已有会话保留原 preset。“PTC 执行方式”一行的“设为持久化”单独保存 `ptc-runtime.executionMode: persistent-in-memory`。切换会重载共享 PTC 执行器,可能打断当前调用;后续调用在内存中保留变量和应用对象。已经使用持久化模式时,按钮显示“已设为持久化”并禁用。
|
||||
从插件列表或详情页启用后,系统权限尚未就绪时会显示设置引导,提供“稍后”和“前往设置”。详情页的“新会话默认模式”提供“设为 PTC”按钮,仅点击此按钮才保存默认值。已有会话保留原 preset。“PTC 执行方式”一行的“设为持久化”单独保存 `ptc-runtime.executionMode: persistent-in-memory`。切换会重载共享 PTC 执行器,可能打断当前调用;后续调用在内存中保留变量和应用对象。已经使用持久化模式时,按钮显示“已设为持久化”并禁用。
|
||||
|
||||
页面展示“始终允许的应用”和“不允许访问的应用”。后者汇总任一会话的拒绝记录,不取消其他会话已有的授权,也不阻止新会话询问授权。会话授权仍在对应会话中生效,详情页不展示这类应用。“撤销”会移除对应应用的访问许可并取消相关操作。已发送到应用的输入无法撤回,继续操作前需重新观察应用。“允许再次询问”清除该应用在所有会话中的拒绝记录;下次选择应用时仍需用户重新决定。
|
||||
|
||||
@@ -61,18 +63,22 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>共享执行与平台职责</summary>
|
||||
|
||||
[授权存储](src/permission-store.ts)复用 `storageDomain` 和配置的后端。默认 JSON 后端写入 `$DSH_HOME/storages/cua_permissions_<sha256>.json`,摘要对应 profile 的真实目录。每次授权检查在 profile 文件锁内打开并读取最新状态,随后关闭存储域;原生操作使用单独的应用锁。撤销会等待其他进程已获准执行的操作结束;撤销完成后,各进程都拒绝旧句柄。不同电脑或 PID 命名空间不能共享这把锁。
|
||||
|
||||
旧 `allowedApps` 只在首次访问存储时导入,迁移后不再应用。确认权限页显示正确后,可以删除旧配置。授权、拒绝和允许再次询问均持久化,页面仍不显示仅对某个会话生效的许可。没有 profile 的独立组合只保留进程内决定;有 profile 但缺少域存储的组合会拒绝授权操作。
|
||||
|
||||
权限 Remote 还通过 `ptcConfiguration()` 和 `enablePersistentPtc()` 支持详情页的执行方式设置。查询读取现有执行器和可编辑的 profile 条目。显式写入使用 `configEditor.edit()` 保留其他字段并按正常 Loader 规则重载;已是持久化模式的执行器保持不变。此设置独立于系统权限和应用授权。
|
||||
|
||||
提供方将准备好的 helper、连接工厂和指引传给 [installCua](src/index.ts)。[NativeClient](src/native-client.ts) 负责请求关联、字节上限、取消确认和失败处理。平台连接器负责启动和连接清理。共享执行器为每个 Agent 保留一个连接,并在销毁时等待进行中的图像写入。
|
||||
|
||||
`agentPreset` 默认为 `ptc`,将工具和指引限制到绑定该 preset 的 Agent。提供方使用现有 `agent/created`、`agent-preset/selected` 事件及 Agent 拥有的作用域,也处理启用前已存在的 Agent。提供方卸载和 Agent 销毁都会释放这些注册。preset 定义可以晚于提供方注册;名称用于筛选已绑定的 Agent,在匹配的 Agent 出现前不提供工具。不使用 preset 的独立组合可设置 `agentPreset: false` 来全局注册。Host 仍拥有权限、本机连接和取消操作,preset 定义保持不变。
|
||||
|
||||
[操作 schema](src/protocol.ts) 定义模型参数,并单独声明 Host 专用字段。原生实现在执行时检查目标身份、窗口几何、资源上限和操作系统访问条件。本库没有独立投影的注册表或持久状态,因此不发布 invariant 模块。
|
||||
[操作 schema](src/protocol.ts) 定义模型参数,并单独声明 Host 专用字段。原生实现在执行时检查目标身份、窗口几何、资源上限和操作系统访问条件。授权存储独立于原生协议保存持久许可。
|
||||
|
||||
|
||||
根 Host 入口导出 `installCua`、`CuaConfig` 和供 Typert 发现的 `CuaAccess` 服务,`apply()` 只标记可选 Client 贡献。`/client` 通过现有模块加载器加载权限界面。Host 和 Client 使用独立编译项目,headless 导入不会加载 React、CSS 或浏览器注册。`/api` 提供应用、窗口 API 及其类型;`/bridge`、`/native-client` 和 `/protocol` 保留运行时和平台消费者。`/types` 仅导出生成 Remote 声明所需的四个命名类型,Client 使用包内类型。`/typert` 提供 Host 反射,`/remote` 提供浏览器描述和编解码,权限判断仍由 Host 执行。
|
||||
|
||||
可选浏览器插件挂载原生 provider 的权限 Remote,复用插件页的启用引导和配置插槽。四排使用条件始终可见,未加载的字段保留占位;查询期间和权限已允许时显示相同的权限管理说明,换行也不会改变内容。默认模式、执行方式、系统权限、应用列表和应用图标分别查询,结果返回后各自显示。应用名称和操作按钮不等待图标,图标位置保留空位。用户操作按顺序执行,不等待尚未完成的查询;卸载时等待操作和查询全部结束,再释放独立持有的 Remote 挂载。已知查询错误和只读设置提示在管理操作或重试期间保持可见,操作按钮禁用。查询错误只显示在对应部分,启用引导只等待系统权限结果。注入层独立于页面观察插件管理操作。组合包和组件行变更会暂停四项查询,并使进行中的结果失效;操作结束后即使页面已关闭,也会重新查询。已知错误保留至读取成功;卸载后的迟到结果会被丢弃。控制器直接接收生成的 `ctx.remote.cuaAccess`,在现有查询和操作流程中处理失败响应,并把查询结果投影到页面和设置弹窗;重新连接或 Host 返回的配置 namespace 更新时刷新状态。操作提示挂载到 shell overlay,关闭页面后仍可显示。provider 负责权限检查、持久授权和取消;浏览器不保存授权决定。
|
||||
可选浏览器插件挂载原生 provider 的权限 Remote,复用插件页的启用引导和配置插槽。四排使用条件始终可见,未加载的字段保留占位;查询期间和权限已允许时显示相同的权限管理说明,换行也不会改变内容。默认模式、执行方式、系统权限、应用列表和应用图标分别查询,结果返回后各自显示。应用名称和操作按钮不等待图标,图标位置保留空位。用户操作按顺序执行,不等待尚未完成的查询;卸载时等待操作和查询全部结束,再释放独立持有的 Remote 挂载。已知查询错误和只读设置提示在管理操作或重试期间保持可见,操作按钮禁用。查询错误只显示在对应部分,启用引导只等待系统权限结果。注入层独立于页面观察插件管理操作。组合包和组件行变更会暂停四项查询,并使进行中的结果失效;操作结束后即使页面已关闭,也会重新查询。已知错误保留至读取成功;卸载后的迟到结果会被丢弃。控制器直接接收生成的 `ctx.remote.cuaAccess`,在现有查询和操作流程中处理失败响应,并把查询结果投影到页面和设置弹窗;重新连接时刷新状态。进入页面、窗口聚焦或操作完成后读取当前授权存储。操作提示挂载到 shell overlay,关闭页面后仍可显示。provider 负责权限检查、持久授权和取消;浏览器不保存授权决定。
|
||||
|
||||
详情页通过现有 `configForms` 服务读取实际默认值(`selectedDefault ?? default`)。默认模式按钮仅写入 `selectedDefault`,沿用表单带版本检查的设置写入流程。执行方式一行调用 CUA 的 `ptcConfiguration()` 和 `enablePersistentPtc()`:Host 通过现有配置编辑器仅修改 `ptc-runtime.executionMode`,保留普通 Loader 重载规则。权限检查和执行方式状态独立返回。设置仍在加载、只读或不可用时不能提交;写入失败时在设置旁显示错误,供用户重试。
|
||||
</details>
|
||||
@@ -84,6 +90,7 @@ kind: "package-reference"
|
||||
|
||||
- [macOS 提供方](../cua-macos/README.zh.md)
|
||||
- [对象接口](src/types.ts)
|
||||
- [授权存储迁移](../../../docs/upgrade-guide/v0.2.1-rc.2/cua-consent-storage/guide.zh.md)
|
||||
|
||||
-----
|
||||
|
||||
@@ -108,7 +115,7 @@ kind: "package-reference"
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 会话授权决策保存在内存中,没有容量上限或淘汰机制,直到提供方卸载才清空。长期运行的 Host 会保留每个曾请求访问的会话与应用组合。
|
||||
- 会话决定没有自动保留期限,也不会随对话删除而自动清理。撤销会移除许可,但保留撤销代次,用于拒绝旧句柄和先前尚未完成的审批。
|
||||
- 当前原生执行使用 macOS 提供方。Windows 支持计划使用独立的提供方包和 C# helper,保留共享应用与窗口 API、授权、快照有效性和动作结果语义。实现须说明系统特有的限制,并区分交叉编译证据和 Windows 桌面验证。
|
||||
- 持久变量不会在一组连续操作期间独占应用。其他 Agent 和用户可能改变其状态。 悬停和观察不恢复先前的鼠标位置;[连续交互](../../../.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md)不分配跨调用所有权。
|
||||
- 本包保留在 `experimental`,Windows 支持和平台验证完成后可迁至 `computer-use` 组。
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
"zod": "^4.4.3",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-computer-use": "workspace:*"
|
||||
"@deepseek-ai/dsh-computer-use": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*",
|
||||
@@ -107,7 +108,6 @@
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
|
||||
"sharp": "^0.35.3",
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:*",
|
||||
"@deepseek-ai/dsh-config-editor": "workspace:*",
|
||||
@@ -132,6 +132,8 @@
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage": "workspace:*",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-user-questions": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:*"
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface CuaSystemPermissionState {
|
||||
}
|
||||
/** Saved grants and refused applications, independent of native permission checks and icons. */
|
||||
export interface CuaApplicationState {
|
||||
/** Owning configuration entry for permission updates; null when this composition has no Loader entry. */
|
||||
settingsNamespace: string | null
|
||||
allowedApps: readonly CuaApplicationGrant[]
|
||||
/** Refusals from any session, excluding saved grants; other sessions may still hold session access. */
|
||||
blockedApps: readonly CuaApplicationGrant[]
|
||||
|
||||
@@ -15,7 +15,6 @@ export type * from './access-types.ts'
|
||||
interface ControlOptions extends AccessOptions {
|
||||
native: NativeClientConfig
|
||||
createChannel: NativeChannelFactory
|
||||
settingsNamespace: string | null
|
||||
}
|
||||
type Control = (method: string, params: Record<string, JsonValue>) => Promise<JsonValue>
|
||||
const permissionProcess = z.object({ pid: z.number().int().positive(), path: z.string().optional(), name: z.string().optional() })
|
||||
@@ -36,11 +35,10 @@ export class CuaAccess extends TypertRemoteService {
|
||||
/**
|
||||
* @param ctx Provider context.
|
||||
* @param policy Application decision owner.
|
||||
* @param settingsNamespace Owning configuration entry.
|
||||
* @param control Serialized native permission dispatch.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly policy: ApplicationAccess,
|
||||
private readonly settingsNamespace: string | null, private readonly control: Control) { super(ctx, 'cuaAccess') }
|
||||
private readonly control: Control) { super(ctx, 'cuaAccess') }
|
||||
|
||||
/** Read PTC execution state separately from native permission checks.
|
||||
* @returns Whether the shared runtime retains memory and has an editable profile entry.
|
||||
@@ -66,13 +64,11 @@ export class CuaAccess extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read application decisions without waiting for the native helper.
|
||||
* @returns Saved grants and refused applications with their persistence owner.
|
||||
* Read fresh stored application decisions without issuing a native request.
|
||||
* @returns Saved grants, refused applications and durable-storage availability.
|
||||
*/
|
||||
@Remote
|
||||
applications(): CuaApplicationState {
|
||||
return { settingsNamespace: this.settingsNamespace, ...this.policy.status() }
|
||||
}
|
||||
applications(): Promise<CuaApplicationState> { return this.policy.status() }
|
||||
|
||||
/**
|
||||
* Inspect system privileges independently of application decisions and icons.
|
||||
@@ -89,7 +85,7 @@ export class CuaAccess extends TypertRemoteService {
|
||||
*/
|
||||
@Remote
|
||||
async applicationIcons(): Promise<Record<string, string>> {
|
||||
const state = this.policy.status()
|
||||
const state = await this.policy.status()
|
||||
const paths = [...new Set([...state.allowedApps, ...state.blockedApps].map(app => app.executablePath))]
|
||||
return paths.length === 0 ? {} : z.record(z.string(), ApplicationIcon).parse(await this.control('applicationIcons', { paths }))
|
||||
}
|
||||
@@ -127,10 +123,8 @@ export class CuaAccess extends TypertRemoteService {
|
||||
* @param id Refused or revoked application displayed on the permission page.
|
||||
* @returns Completion after clearing refusals; no permission is granted and no action is replayed.
|
||||
*/
|
||||
/* oxlint-disable typescript/require-await -- Remote callers receive policy failures as rejected promises. */
|
||||
@Remote
|
||||
async retryApproval(id: CuaApplicationId): Promise<void> { this.policy.retryApproval(id) }
|
||||
/* oxlint-enable typescript/require-await */
|
||||
async retryApproval(id: CuaApplicationId): Promise<void> { await this.policy.retryApproval(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +156,6 @@ export function installCuaAccess(ctx: Context, options: ControlOptions): Pick<Ap
|
||||
await Promise.allSettled([policy.dispose(), tail])
|
||||
await client?.dispose()
|
||||
}, 'cua-access.dispose')
|
||||
new CuaAccess(ctx, policy, options.settingsNamespace, control)
|
||||
new CuaAccess(ctx, policy, control)
|
||||
return { select: policy.select.bind(policy), run: policy.run.bind(policy), forget: policy.forget.bind(policy), dispose }
|
||||
}
|
||||
@@ -8,20 +8,21 @@ import { NativeError, type NativeClient } from './native-client.ts'
|
||||
import type { CuaAppHandle } from './types.ts'
|
||||
import type { CuaApplicationGrant, CuaApplicationId } from './application-types.ts'
|
||||
import type { CuaApplicationState } from './access-types.ts'
|
||||
import { CuaPermissionStore, type ApplicationPermission, type PermissionTransaction } from './permission-store.ts'
|
||||
|
||||
/** Native identity admission occurs before any application observation or input. */
|
||||
export const ApplicationAuthorizationResult = z.object({
|
||||
id: z.string().regex(/^[a-f0-9]{64}$/).transform(value => value as CuaApplicationId),
|
||||
name: z.string(), executablePath: z.string().min(1), persistable: z.boolean(),
|
||||
})
|
||||
/** Current saved grants and their optional profile writer. */
|
||||
export interface AccessOptions {
|
||||
grants: () => readonly CuaApplicationGrant[]
|
||||
save?: ((grants: readonly CuaApplicationGrant[]) => Promise<void>) | undefined
|
||||
}
|
||||
/** Application consent uses one profile store, including conversation-scoped decisions. */
|
||||
export interface AccessOptions { store: CuaPermissionStore }
|
||||
interface Selection {
|
||||
identity: CuaApplicationGrant
|
||||
scope: 'session' | 'persistent'
|
||||
sessionId: Agent['id']
|
||||
revoked: number
|
||||
revision: number
|
||||
lifetime: AbortController
|
||||
}
|
||||
interface ClientSelections {
|
||||
@@ -39,16 +40,13 @@ interface ActiveOperation {
|
||||
/** Owns application consent and cancels operations when saved access is removed. */
|
||||
export class ApplicationAccess {
|
||||
private readonly clients = new Map<NativeClient, ClientSelections>()
|
||||
private readonly decisions = new Map<Agent['id'], Map<CuaApplicationId, { identity: CuaApplicationGrant; allowed: boolean }>>()
|
||||
private readonly active = new Set<ActiveOperation>()
|
||||
private readonly selections = new Map<Agent['id'], Map<CuaApplicationId, Promise<void>>>()
|
||||
private readonly lifetime = new AbortController()
|
||||
private mutationTail: Promise<unknown> = Promise.resolve()
|
||||
private disposal: Promise<void> | undefined
|
||||
|
||||
/** @param ctx Host context owning this provider. @param options Live saved grants and profile persistence. */
|
||||
constructor(private readonly ctx: Context, private readonly options: AccessOptions) {
|
||||
ctx.on('loader/volatile-update', () => { this.reconcile() })
|
||||
ctx.effect(() => () => this.dispose(), 'cua-application-access.dispose')
|
||||
}
|
||||
|
||||
@@ -59,9 +57,8 @@ export class ApplicationAccess {
|
||||
dispose(): Promise<void> {
|
||||
return this.disposal ??= (async () => {
|
||||
this.lifetime.abort()
|
||||
await Promise.allSettled([this.mutationTail, ...[...this.active].map(operation => operation.done.promise)])
|
||||
await Promise.allSettled([this.options.store.drain(), ...[...this.active].map(operation => operation.done.promise)])
|
||||
this.clients.clear()
|
||||
this.decisions.clear()
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -69,34 +66,38 @@ export class ApplicationAccess {
|
||||
* Project application decisions without requesting native permissions.
|
||||
* @returns Current saved grants and refused applications for the permission page.
|
||||
*/
|
||||
status(): Omit<CuaApplicationState, 'settingsNamespace'> {
|
||||
const allowedApps = [...this.options.grants()]
|
||||
const blockedApps = new Map<CuaApplicationId, CuaApplicationGrant>()
|
||||
for (const decisions of this.decisions.values()) for (const [id, decision] of decisions) {
|
||||
if (decision.allowed || allowedApps.some(grant => grant.id === id)) continue
|
||||
blockedApps.set(id, decision.identity)
|
||||
}
|
||||
return { allowedApps,
|
||||
blockedApps: [...blockedApps.values()], persistentAvailable: this.options.save !== undefined }
|
||||
async status(): Promise<CuaApplicationState> {
|
||||
return this.options.store.withState(this.lifetime.signal, ({ state }) => {
|
||||
const allowedApps: CuaApplicationGrant[] = [], blockedApps: CuaApplicationGrant[] = []
|
||||
for (const app of Object.values(state.applications)) {
|
||||
if (app.always) allowedApps.push(app.always)
|
||||
else {
|
||||
const denied = Object.values(app.sessions).find(session => session.decision === 'deny')
|
||||
if (denied) blockedApps.push(denied.identity)
|
||||
}
|
||||
}
|
||||
return { allowedApps, blockedApps, persistentAvailable: this.options.store.persistent }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove saved and session consent and cancel affected native operations.
|
||||
* Remove saved and session consent and join affected native operations.
|
||||
* @param id Application identity shown in the permission page.
|
||||
* @returns Completion after persistence and affected operations settle; already dispatched input cannot be undone.
|
||||
* @returns Completion after persistence and cancellation, including other processes' admitted operations.
|
||||
*/
|
||||
async revoke(id: CuaApplicationId): Promise<void> {
|
||||
this.lifetime.signal.throwIfAborted()
|
||||
let clients: readonly NativeClient[] = []
|
||||
await this.mutate(async () => {
|
||||
const grants = this.options.grants()
|
||||
if (grants.some(grant => grant.id === id)) {
|
||||
const save = this.options.save
|
||||
if (!save) throw new Error('Application permissions cannot be saved in this composition')
|
||||
await save(grants.filter(grant => grant.id !== id))
|
||||
}
|
||||
clients = this.invalidate(id)
|
||||
})
|
||||
id = ApplicationAuthorizationResult.shape.id.parse(id)
|
||||
const clients = this.invalidate(id)
|
||||
await this.options.store.withApplication(id, this.lifetime.signal, () =>
|
||||
this.options.store.withState(this.lifetime.signal, async (transaction) => {
|
||||
const app = transaction.state.applications[id] ?? this.empty()
|
||||
if (app.always && !this.options.store.persistent) throw new Error('Application permissions cannot be saved in this composition')
|
||||
await this.save(transaction, id, { revoked: app.revoked + 1, sessions: Object.fromEntries(
|
||||
Object.entries(app.sessions).map(([sessionId, session]) => [sessionId, { ...session, decision: 'deny' as const, revision: session.revision + 1 }]),
|
||||
) })
|
||||
}),
|
||||
)
|
||||
const results = await Promise.allSettled([
|
||||
...clients.map(client => client.dispose()),
|
||||
...[...this.active].filter(operation => operation.identity.id === id).map(operation => operation.done.promise),
|
||||
@@ -106,16 +107,19 @@ export class ApplicationAccess {
|
||||
if (errors.length) throw new AggregateError(errors, 'Application access was revoked, but helper cleanup failed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear explicit refusal; the next getApp still requires a new user decision.
|
||||
* @param id Application identity selected by the human.
|
||||
/** Clear refusals without granting access or restoring old handles.
|
||||
* @param id Application selected by the human.
|
||||
* @returns Completion after the new asking policy is durable.
|
||||
*/
|
||||
retryApproval(id: CuaApplicationId): void {
|
||||
this.lifetime.signal.throwIfAborted()
|
||||
for (const decisions of this.decisions.values()) if (decisions.get(id)?.allowed === false) decisions.delete(id)
|
||||
for (const state of this.clients.values()) for (const [handle, selected] of state.handles) {
|
||||
if (selected.identity.id === id && selected.lifetime.signal.aborted) state.handles.delete(handle)
|
||||
}
|
||||
async retryApproval(id: CuaApplicationId): Promise<void> {
|
||||
id = ApplicationAuthorizationResult.shape.id.parse(id)
|
||||
await this.options.store.withState(this.lifetime.signal, async (transaction) => {
|
||||
const app = transaction.state.applications[id]
|
||||
if (!app || !Object.values(app.sessions).some(session => session.decision === 'deny')) return
|
||||
await this.save(transaction, id, { ...app, sessions: Object.fromEntries(Object.entries(app.sessions).map(([sessionId, session]) => [
|
||||
sessionId, session.decision === 'deny' ? { ...session, decision: 'ask' as const, revision: session.revision + 1 } : session,
|
||||
])) })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,32 +136,38 @@ export class ApplicationAccess {
|
||||
identity: CuaApplicationGrant & { persistable: boolean }, signal: AbortSignal, icon?: string): Promise<void> {
|
||||
await this.withOperation(agent, client, identity, signal, signal => this.selectInOrder(agent, identity.id, async () => {
|
||||
signal.throwIfAborted()
|
||||
const observed = await this.read(identity.id, signal)
|
||||
const existing = this.clients.get(client)?.handles.get(handle)
|
||||
if (!client.disposed && existing && this.permitted(existing)) return
|
||||
const saved = identity.persistable && this.options.grants().some(grant => grant.id === identity.id)
|
||||
const decision = this.decisions.get(agent.id)?.get(identity.id)
|
||||
if (!client.disposed && existing) {
|
||||
if (this.permitted(existing, observed)) return
|
||||
this.invalidate(identity.id, agent)
|
||||
throw new NativeError('APP_ACCESS_REVOKED', 'Application access changed; acquire a fresh application handle after consent')
|
||||
}
|
||||
const saved = identity.persistable && observed.always !== undefined
|
||||
const decision = observed.sessions[agent.id]
|
||||
let scope: Selection['scope'] = saved ? 'persistent' : 'session'
|
||||
if (!saved && decision?.allowed === false) throw this.denied(identity)
|
||||
if (!saved && decision?.decision === 'deny') throw this.denied(identity)
|
||||
if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles')
|
||||
if (!saved && !decision?.allowed) {
|
||||
if (!saved && decision?.decision !== 'allow') {
|
||||
const questions = this.ctx.get('userQuestions')
|
||||
if (!questions) throw new NativeError('APP_APPROVAL_UNAVAILABLE', 'No human answerer is available for application access')
|
||||
const options = [{ label: 'Allow for this session' }, { label: 'Deny' }]
|
||||
if (identity.persistable && this.options.save) options.push({ label: 'Always allow' })
|
||||
if (identity.persistable && this.options.store.persistent) options.push({ label: 'Always allow' })
|
||||
let result: AskUserQuestionAnswer
|
||||
try {
|
||||
result = await questions.ask({ agent, signal, questions: [{ id: 'cua-app-access',
|
||||
question: `Allow computer use to launch if needed, read, capture, and control ${identity.name}?`,
|
||||
detail: 'Session access lasts until DSH restarts or computer use is disabled.',
|
||||
detail: 'Session access applies only to this conversation.',
|
||||
options, intent: {
|
||||
kind: 'approval', approve: 'Allow for this session', reject: 'Deny',
|
||||
...(identity.persistable && this.options.save ? { always: 'Always allow' } : {}),
|
||||
...(identity.persistable && this.options.store.persistent ? { always: 'Always allow' } : {}),
|
||||
subject: { name: identity.name, ...(icon === undefined ? {} : { icon }) },
|
||||
display: applicationAccessDisplay,
|
||||
} }] })
|
||||
} catch (error) {
|
||||
if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') {
|
||||
this.invalidate(identity.id, agent)
|
||||
if (!this.lifetime.signal.aborted) await this.decide(agent.id, identity, 'deny')
|
||||
throw this.denied(identity)
|
||||
}
|
||||
if (error instanceof UserQuestionError && error.code === 'DELEGATED_CALLER') {
|
||||
@@ -169,21 +179,39 @@ export class ApplicationAccess {
|
||||
const answer = result.answers.find(answer => answer.id === 'cua-app-access')
|
||||
const chosen = answer && !answer.custom?.trim() && answer.selected.length === 1 ? answer.selected[0] : undefined
|
||||
if (chosen === 'Allow for this session') scope = 'session'
|
||||
else if (chosen === 'Always allow' && identity.persistable && this.options.save) {
|
||||
scope = 'persistent'
|
||||
const { persistable: _persistable, ...grant } = identity
|
||||
await this.persist((grants) => {
|
||||
signal.throwIfAborted()
|
||||
return [...grants.filter(item => item.id !== identity.id), grant]
|
||||
})
|
||||
} else {
|
||||
else if (chosen === 'Always allow' && identity.persistable && this.options.store.persistent) scope = 'persistent'
|
||||
else {
|
||||
await this.decide(agent.id, identity, 'deny')
|
||||
this.invalidate(identity.id, agent)
|
||||
throw this.denied(identity)
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Awaiting approval can close the native client.
|
||||
if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles')
|
||||
const authorized = await this.options.store.withState(signal, async (transaction) => {
|
||||
signal.throwIfAborted()
|
||||
const app = transaction.state.applications[identity.id] ?? this.empty()
|
||||
if (app.revoked !== observed.revoked
|
||||
|| app.sessions[agent.id]?.revision !== observed.sessions[agent.id]?.revision) {
|
||||
throw new NativeError('APP_ACCESS_REVOKED', 'Application consent changed while awaiting approval; select the application again')
|
||||
}
|
||||
if (scope === 'persistent' && !saved && !this.options.store.persistent) throw this.denied(identity)
|
||||
const { persistable: _persistable, ...grant } = identity
|
||||
const previous = app.sessions[agent.id]
|
||||
if (previous?.decision === 'allow' && (scope === 'session' || app.always)) {
|
||||
return { revoked: app.revoked, revision: previous.revision }
|
||||
}
|
||||
const revision = (previous?.revision ?? 0) + 1
|
||||
const next = { ...app,
|
||||
...(scope === 'persistent' ? { always: grant } : {}),
|
||||
sessions: { ...app.sessions, [agent.id]: { identity: grant, decision: 'allow' as const,
|
||||
revision } },
|
||||
}
|
||||
await this.save(transaction, identity.id, next)
|
||||
return { revoked: next.revoked, revision }
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
// The awaited user decision can outlive the helper, so re-read its live getter.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Persisting consent can close the native client.
|
||||
if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles')
|
||||
const state = this.clients.get(client) ?? { agent, handles: new Map<CuaAppHandle, Selection>() }
|
||||
const { persistable: _persistable, ...grant } = identity
|
||||
@@ -191,10 +219,9 @@ export class ApplicationAccess {
|
||||
for (const selected of previousState.handles.values()) selected.lifetime.abort()
|
||||
this.clients.delete(previous)
|
||||
}
|
||||
state.handles.set(handle, { identity: grant, scope, lifetime: new AbortController() })
|
||||
state.handles.set(handle, { identity: grant, scope, sessionId: agent.id, revoked: authorized.revoked,
|
||||
revision: authorized.revision, lifetime: new AbortController() })
|
||||
this.clients.set(client, state)
|
||||
if (scope === 'session') this.decide(agent.id, grant, true)
|
||||
else this.decisions.get(agent.id)?.delete(identity.id)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -211,12 +238,18 @@ export class ApplicationAccess {
|
||||
const state = this.clients.get(client)
|
||||
const selected = state?.handles.get(handle)
|
||||
if (!state || !selected) throw new NativeError('APP_ACCESS_REQUIRED', 'Select the application with getApp and obtain user consent before reading or controlling it')
|
||||
if (!this.permitted(selected)) {
|
||||
if (!selected.lifetime.signal.aborted) this.invalidate(selected.identity.id, state.agent)
|
||||
throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application')
|
||||
}
|
||||
if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles')
|
||||
return this.withOperation(state.agent, client, selected.identity, AbortSignal.any([signal, selected.lifetime.signal]), dispatch)
|
||||
if (selected.lifetime.signal.aborted) throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application')
|
||||
const lifetime = AbortSignal.any([signal, this.lifetime.signal, selected.lifetime.signal])
|
||||
return this.options.store.withApplication(selected.identity.id, lifetime, async () => {
|
||||
const permitted = await this.options.store.withState(lifetime, ({ state: permissions }) =>
|
||||
this.permitted(selected, permissions.applications[selected.identity.id] ?? this.empty()))
|
||||
if (!permitted) {
|
||||
this.invalidate(selected.identity.id, state.agent)
|
||||
throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application')
|
||||
}
|
||||
if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles')
|
||||
return this.withOperation(state.agent, client, selected.identity, lifetime, dispatch)
|
||||
})
|
||||
}
|
||||
|
||||
private async selectInOrder(agent: Agent, id: CuaApplicationId, select: () => Promise<void>): Promise<void> {
|
||||
@@ -257,54 +290,43 @@ export class ApplicationAccess {
|
||||
for (const [client, state] of this.clients) if (state.agent === agent) this.clients.delete(client)
|
||||
}
|
||||
|
||||
private permitted(selection: Selection): boolean {
|
||||
return !selection.lifetime.signal.aborted
|
||||
&& (selection.scope === 'session' || this.options.grants().some(grant => grant.id === selection.identity.id))
|
||||
private permitted(selection: Selection, app: ApplicationPermission): boolean {
|
||||
const session = app.sessions[selection.sessionId]
|
||||
return !selection.lifetime.signal.aborted && app.revoked === selection.revoked
|
||||
&& (selection.scope === 'persistent' ? app.always !== undefined
|
||||
: session?.decision === 'allow' && session.revision === selection.revision)
|
||||
}
|
||||
private denied(identity: CuaApplicationGrant): NativeError {
|
||||
return new NativeError('APP_ACCESS_DENIED', `The user denied access to ${identity.name}. Do not retry or use another tool to bypass this decision.`)
|
||||
}
|
||||
private decide(sessionId: Agent['id'], identity: CuaApplicationGrant, allowed: boolean): void {
|
||||
const decisions = this.decisions.get(sessionId) ?? new Map<CuaApplicationId, { identity: CuaApplicationGrant; allowed: boolean }>()
|
||||
decisions.set(identity.id, { identity: { id: identity.id, name: identity.name, executablePath: identity.executablePath }, allowed })
|
||||
this.decisions.set(sessionId, decisions)
|
||||
private async decide(sessionId: Agent['id'], identity: CuaApplicationGrant, decision: 'allow' | 'deny'): Promise<void> {
|
||||
await this.options.store.withState(this.lifetime.signal, async (transaction) => {
|
||||
const app = transaction.state.applications[identity.id] ?? this.empty()
|
||||
await this.save(transaction, identity.id, { ...app, sessions: { ...app.sessions,
|
||||
[sessionId]: { identity: { id: identity.id, name: identity.name, executablePath: identity.executablePath }, decision,
|
||||
revision: (app.sessions[sessionId]?.revision ?? 0) + 1 },
|
||||
} })
|
||||
})
|
||||
}
|
||||
private invalidate(id: CuaApplicationId, agent?: Agent): NativeClient[] {
|
||||
const clients = new Set<NativeClient>()
|
||||
for (const [sessionId, decisions] of this.decisions) {
|
||||
const decision = decisions.get(id)
|
||||
if (decision && (agent === undefined || sessionId === agent.id)) this.decide(sessionId, decision.identity, false)
|
||||
}
|
||||
for (const [client, state] of this.clients) for (const selection of state.handles.values()) if (selection.identity.id === id
|
||||
&& (agent === undefined || state.agent.id === agent.id)) {
|
||||
selection.lifetime.abort(new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application'))
|
||||
this.decide(state.agent.id, selection.identity, false)
|
||||
clients.add(client)
|
||||
}
|
||||
for (const operation of this.active) if (operation.identity.id === id && (agent === undefined || operation.agent.id === agent.id)) {
|
||||
this.decide(operation.agent.id, operation.identity, false)
|
||||
operation.controller.abort(this.denied(operation.identity))
|
||||
clients.add(operation.client)
|
||||
}
|
||||
for (const client of clients) client.invalidate()
|
||||
return [...clients]
|
||||
}
|
||||
private reconcile(): void {
|
||||
for (const state of this.clients.values()) for (const selection of state.handles.values()) {
|
||||
if (!selection.lifetime.signal.aborted && !this.permitted(selection)) this.invalidate(selection.identity.id)
|
||||
}
|
||||
private empty(): ApplicationPermission { return { revoked: 0, sessions: {} } }
|
||||
private read(id: CuaApplicationId, signal = this.lifetime.signal): Promise<ApplicationPermission> {
|
||||
return this.options.store.withState(signal, ({ state }) => state.applications[id] ?? this.empty())
|
||||
}
|
||||
private persist(update: (grants: readonly CuaApplicationGrant[]) => readonly CuaApplicationGrant[]): Promise<void> {
|
||||
return this.mutate(async () => {
|
||||
const save = this.options.save
|
||||
if (!save) throw new Error('Application permissions cannot be saved in this composition')
|
||||
await save(update(this.options.grants()))
|
||||
})
|
||||
}
|
||||
private mutate(mutate: () => Promise<void>): Promise<void> {
|
||||
const perform = async () => { this.lifetime.signal.throwIfAborted(); await mutate() }
|
||||
const operation = this.mutationTail.then(perform, perform)
|
||||
this.mutationTail = operation
|
||||
return operation
|
||||
private save(transaction: PermissionTransaction, id: CuaApplicationId, app: ApplicationPermission): Promise<void> {
|
||||
return transaction.save({ ...transaction.state, applications: { ...transaction.state.applications, [id]: app } })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
.panels { display: grid; gap: 24px; margin: 18px 0; font-size: 13px; }
|
||||
.panels { display: grid; gap: 32px; margin: 0; font-size: 13px; }
|
||||
.panel { display: grid; gap: 12px; min-width: 0; }
|
||||
.card { min-width: 0; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: var(--dsw-radius-md); }
|
||||
.card > * + * { border-top: 0.5px solid var(--dsw-alias-border-l1); }
|
||||
.card { min-width: 0; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); }
|
||||
.card > * + * { border-top: 0.5px solid var(--dsw-alias-border-l3); }
|
||||
.panel p { margin: 0; color: var(--dsw-alias-label-secondary); line-height: 1.6; }
|
||||
.panel h3 { margin: 0; font-size: 14px; font-weight: 500; }
|
||||
.panel h4 { margin: 0; font-size: 13px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
|
||||
|
||||
@@ -63,9 +63,6 @@ export async function mountCuaPermissions(ctx: Context, contribution: TypertRemo
|
||||
}
|
||||
ctx.effect(() => () => controller.dispose())
|
||||
ctx.on('connection/reset', face.refresh)
|
||||
ctx.effect(() => ctx.remote.$on('settings/document-updated', (namespace) => {
|
||||
if (namespace === controller.state.getSnapshot().applications.value?.settingsNamespace) face.refresh()
|
||||
}))
|
||||
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
||||
name: 'plugins.bundle.config', key: '@deepseek-ai/dsh-cua-bundle', locale: NS, inject: () => settings,
|
||||
}, PermissionPanel))
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'node:path'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import type { Context, Volatile } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { ComputerUseProviderName } from '@deepseek-ai/dsh-computer-use/brand'
|
||||
@@ -13,6 +13,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { z } from 'zod'
|
||||
import { NativeClient, NativeError, type NativeClientConfig, type NativeChannelFactory } from './native-client.ts'
|
||||
import { CuaPermissionStore } from './permission-store.ts'
|
||||
import { ApplicationAuthorizationResult, installCuaAccess } from './access.ts'
|
||||
import type { CuaApplicationGrant, CuaApplicationId } from './access-types.ts'
|
||||
import type { CuaAppHandle, CuaOperation } from './types.ts'
|
||||
@@ -25,7 +26,6 @@ import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-subprocess'
|
||||
import type {} from '@deepseek-ai/dsh-computer-use'
|
||||
import type {} from '@deepseek-ai/dsh-ptc-runtime'
|
||||
import type {} from '@deepseek-ai/dsh-settings'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
export { CuaAccess } from './access.ts'
|
||||
@@ -34,11 +34,11 @@ export { CuaAccess } from './access.ts'
|
||||
export interface CuaConfig {
|
||||
/** Register tools for Agents using this preset; defaults to ptc. False enables global registration. */
|
||||
agentPreset: string | false
|
||||
/** Signed applications explicitly approved for future tasks in this profile. */
|
||||
allowedApps: Volatile<Array<Omit<CuaApplicationGrant, 'id'> & {
|
||||
/** Legacy grants imported once into profile-scoped storage; later edits are ignored. */
|
||||
allowedApps: Array<Omit<CuaApplicationGrant, 'id'> & {
|
||||
/** Unbranded digest at the profile configuration boundary; admitted by the Config schema. */
|
||||
id: string
|
||||
}>>
|
||||
}>
|
||||
/** Absolute native helper override; omission prepares the package-owned executable. */
|
||||
helperPath?: string
|
||||
/** Maximum time in milliseconds for native component preparation. */
|
||||
@@ -83,7 +83,7 @@ export const CuaConfig = Schema.object({
|
||||
allowedApps: Schema.array(Schema.object({
|
||||
id: Schema.string().pattern(/^[a-f0-9]{64}$/).required(),
|
||||
name: Schema.string().required(), executablePath: Schema.string().min(1).required(),
|
||||
})).default([]).volatile(),
|
||||
})).default([]),
|
||||
helperPath: Schema.string(),
|
||||
buildTimeoutMs: Schema.number().step(1).min(1).max(2 ** 31 - 1).default(300_000),
|
||||
timeoutMs: Schema.number().step(1).min(1).max(2 ** 31 - 1).default(30_000),
|
||||
@@ -165,21 +165,9 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend)
|
||||
maxRequestBytes: config.maxRequestBytes, maxResponseBytes: config.maxResponseBytes, graceMs: config.graceMs,
|
||||
textEventIntervalMs: config.textEventIntervalMs, maxClickHoldMs: config.maxClickHoldMs,
|
||||
}
|
||||
const entryId = ctx.fiber.entry?.options.id
|
||||
ctx.inject(['settings'], scope => scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber)))
|
||||
const access = installCuaAccess(ctx, {
|
||||
settingsNamespace: entryId ?? null,
|
||||
native, createChannel: backend.createChannel,
|
||||
grants: () => config.allowedApps.get().map(grant => ({ ...grant, id: grant.id as CuaApplicationId })),
|
||||
get save() {
|
||||
const settings = ctx.get('settings')
|
||||
if (entryId === undefined || settings === undefined) return undefined
|
||||
const descriptor = settings.describe().find(entry => entry.ns === entryId)
|
||||
if (!descriptor) return undefined
|
||||
return async (grants: readonly CuaApplicationGrant[]) => {
|
||||
await settings.mutate(entryId, [{ op: 'set', path: ['allowedApps'], value: grants }], descriptor.revision)
|
||||
}
|
||||
},
|
||||
store: new CuaPermissionStore(ctx, config.allowedApps.map(grant => ({ ...grant, id: grant.id as CuaApplicationId })), config.timeoutMs),
|
||||
})
|
||||
const clients = new Map<Agent, NativeClient>()
|
||||
const lifetime = new AbortController()
|
||||
|
||||
@@ -3,56 +3,56 @@
|
||||
/** English application-access messages. */
|
||||
export const en = {
|
||||
question: 'Allow computer use to launch, read, capture and control this application?',
|
||||
detail: 'Session access lasts until DSH restarts or computer use is disabled.',
|
||||
detail: 'Session access applies only to this conversation.',
|
||||
approve: 'Allow for this session',
|
||||
}
|
||||
|
||||
/** Chinese application-access messages. */
|
||||
export const zh = {
|
||||
question: '允许 Computer Use 启动、读取、截图和控制此应用吗?',
|
||||
detail: '会话授权在 DSH 重启或关闭 Computer Use 后失效。',
|
||||
detail: '会话授权仅在当前对话中生效。',
|
||||
approve: '本会话允许',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** Japanese application-access messages. */
|
||||
export const ja = {
|
||||
question: 'Computer Use によるこのアプリの起動、読み取り、画面キャプチャ、操作を許可しますか?',
|
||||
detail: 'セッションの許可は DSH の再起動または Computer Use の無効化まで有効です。',
|
||||
detail: 'セッションの許可はこの会話にのみ適用されます。',
|
||||
approve: 'このセッションで許可',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** Spanish dictionary, checked against the English keys. */
|
||||
export const es = {
|
||||
question: '¿Permitir que Computer Use inicie, lea, capture y controle esta aplicación?',
|
||||
detail: 'El acceso de esta sesión dura hasta que se reinicie DSH o se desactive Computer Use.',
|
||||
detail: 'El acceso de sesión solo se aplica a esta conversación.',
|
||||
approve: 'Permitir durante esta sesión',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** French dictionary, checked against the English keys. */
|
||||
export const fr = {
|
||||
question: 'Autoriser Computer Use à lancer, lire, capturer et contrôler cette application ?',
|
||||
detail: 'L’autorisation de cette session reste valable jusqu’au redémarrage de DSH ou à la désactivation de Computer Use.',
|
||||
detail: 'L’autorisation de session s’applique uniquement à cette conversation.',
|
||||
approve: 'Autoriser pour cette session',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** German dictionary, checked against the English keys. */
|
||||
export const de = {
|
||||
question: 'Darf Computer Use diese Anwendung starten, lesen, aufnehmen und steuern?',
|
||||
detail: 'Der Zugriff für diese Sitzung gilt, bis DSH neu gestartet oder Computer Use deaktiviert wird.',
|
||||
detail: 'Der Sitzungszugriff gilt nur für diese Unterhaltung.',
|
||||
approve: 'Für diese Sitzung zulassen',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** Russian dictionary, checked against the English keys. */
|
||||
export const ru = {
|
||||
question: 'Разрешить Computer Use запуск, чтение, захват и управление этим приложением?',
|
||||
detail: 'Доступ для этого сеанса действует до перезапуска DSH или отключения Computer Use.',
|
||||
detail: 'Доступ для сеанса действует только в этом разговоре.',
|
||||
approve: 'Разрешить для этого сеанса',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** Portuguese dictionary, checked against the English keys. */
|
||||
export const pt = {
|
||||
question: 'Permitir que o Computer Use inicie, leia, capture e controle este aplicativo?',
|
||||
detail: 'O acesso desta sessão dura até que o DSH seja reiniciado ou o Computer Use seja desativado.',
|
||||
detail: 'O acesso da sessão se aplica apenas a esta conversa.',
|
||||
approve: 'Permitir nesta sessão',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/** Profile-scoped application consent over the existing domain storage service. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineDomain } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import type {} from '@deepseek-ai/dsh-app-boot'
|
||||
import { z } from 'zod'
|
||||
import type { CuaApplicationGrant, CuaApplicationId } from './application-types.ts'
|
||||
|
||||
const identity = z.object({
|
||||
id: z.string().regex(/^[a-f0-9]{64}$/).transform(value => value as CuaApplicationId),
|
||||
name: z.string(), executablePath: z.string().min(1),
|
||||
})
|
||||
const sessionDecision = z.object({
|
||||
identity, decision: z.enum(['allow', 'deny', 'ask']), revision: z.number().int().nonnegative(),
|
||||
})
|
||||
const application = z.object({
|
||||
revoked: z.number().int().nonnegative(), always: identity.optional(),
|
||||
sessions: z.record(z.string(), sessionDecision),
|
||||
})
|
||||
const state = z.object({
|
||||
migrated: z.boolean(), applications: z.record(z.string().regex(/^[a-f0-9]{64}$/), application),
|
||||
}).superRefine((value, context) => {
|
||||
for (const [id, app] of Object.entries(value.applications)) {
|
||||
if ((app.always !== undefined && app.always.id !== id)
|
||||
|| Object.values(app.sessions).some(session => session.identity.id !== id)) {
|
||||
context.addIssue({ code: 'custom', message: 'Application identity does not match its stored key' })
|
||||
}
|
||||
}
|
||||
})
|
||||
/** One application's grants and revocation generations. */
|
||||
export type ApplicationPermission = z.infer<typeof application>
|
||||
/** Validated authoritative state; an empty migrated store never reimports legacy grants. */
|
||||
export type PermissionState = z.infer<typeof state>
|
||||
/** One locked storage operation, with writes resolving only after durability. */
|
||||
export interface PermissionTransaction {
|
||||
state: PermissionState
|
||||
save: (next: PermissionState) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Open fresh domain state under a profile lock for every decision and native operation.
|
||||
* Without a profile, only process-local session decisions are writable. A profile requires domain storage.
|
||||
*/
|
||||
export class CuaPermissionStore {
|
||||
private readonly directory: string | undefined
|
||||
private memory: PermissionState
|
||||
private readonly pending = new Set<Promise<unknown>>()
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
/**
|
||||
* @param ctx Provider context with optional profile and domain storage services.
|
||||
* @param legacy Grants imported on the first access to this profile's store.
|
||||
* @param waitMs Existing operation budget, also bounding profile-lock contention.
|
||||
*/
|
||||
constructor(private readonly ctx: Context, private readonly legacy: readonly CuaApplicationGrant[], private readonly waitMs: number) {
|
||||
this.directory = ctx.get('profileContext')?.dir
|
||||
this.memory = this.importLegacy()
|
||||
}
|
||||
/** Whether this composition can retain application and session grants across restarts. */
|
||||
get persistent(): boolean { return this.directory !== undefined && this.ctx.get('storageDomain') !== undefined }
|
||||
|
||||
/**
|
||||
* Serialize a read, write, or authorized dispatch against all other users of this profile.
|
||||
* @param signal Cancellation while waiting; admitted operations own their cancellation.
|
||||
* @param operation Work using fresh state. Native dispatch uses its separate application lease.
|
||||
* @returns The operation's result after its domain handle has closed.
|
||||
*/
|
||||
withState<T>(signal: AbortSignal, operation: (transaction: PermissionTransaction) => T | Promise<T>): Promise<T> {
|
||||
const pending = this.operate(signal, operation)
|
||||
this.pending.add(pending)
|
||||
void pending.finally(() => { this.pending.delete(pending) }).catch(() => {})
|
||||
return pending
|
||||
}
|
||||
/**
|
||||
* Coordinate native dispatch and whole-application revocation without blocking other applications.
|
||||
* @param id Validated native application identity.
|
||||
* @param signal Cancellation while waiting for admission.
|
||||
* @param operation Authorized dispatch or revocation, after checking fresh state separately.
|
||||
* @returns Result after releasing the application lease.
|
||||
*/
|
||||
withApplication<T>(id: CuaApplicationId, signal: AbortSignal, operation: () => Promise<T>): Promise<T> {
|
||||
const pending = (async () => {
|
||||
signal.throwIfAborted()
|
||||
if (this.directory === undefined) return operation()
|
||||
const directory = await realpath(this.directory)
|
||||
return withFileLock(join(directory, `cua-application-${id}`), operation, { signal, waitMs: this.waitMs })
|
||||
})()
|
||||
this.pending.add(pending)
|
||||
void pending.finally(() => { this.pending.delete(pending) }).catch(() => {})
|
||||
return pending
|
||||
}
|
||||
|
||||
/** Join accepted operations before withdrawing this provider. */
|
||||
async drain(): Promise<void> { await Promise.allSettled(this.pending) }
|
||||
|
||||
private async operate<T>(signal: AbortSignal, operation: (transaction: PermissionTransaction) => T | Promise<T>): Promise<T> {
|
||||
if (this.directory === undefined) {
|
||||
const run = async (): Promise<T> => {
|
||||
signal.throwIfAborted()
|
||||
return operation({ state: this.memory, save: (next) => { this.memory = next; return Promise.resolve() } })
|
||||
}
|
||||
const pending = this.tail.then(run)
|
||||
this.tail = pending.then(() => {}, () => {})
|
||||
return pending
|
||||
}
|
||||
const storage = this.ctx.get('storageDomain')
|
||||
if (storage === undefined) throw new Error('Profile application permissions require storageDomain')
|
||||
const directory = await realpath(this.directory)
|
||||
const name = `cua_permissions_${createHash('sha256').update(directory).digest('hex')}`
|
||||
const spec = defineDomain({ name, version: 1, tables: {}, global: {
|
||||
schema: state, initial: { migrated: false, applications: {} },
|
||||
} })
|
||||
return withFileLock(join(directory, 'cua-permissions'), async () => {
|
||||
const domain = await storage.open(spec)
|
||||
try {
|
||||
if (!domain.global.get().migrated) await domain.global.set(this.importLegacy())
|
||||
return await operation({ state: domain.global.get(), save: next => domain.global.set(next) })
|
||||
} finally { await domain.close() }
|
||||
}, { signal, waitMs: this.waitMs })
|
||||
}
|
||||
|
||||
private importLegacy(): PermissionState {
|
||||
return { migrated: true, applications: Object.fromEntries(this.legacy.map(grant => [grant.id, {
|
||||
revoked: 0, always: grant, sessions: {},
|
||||
}])) }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ApplicationAuthorizationResult, installCuaAccess } from '../src/access.
|
||||
import type { CuaApplicationGrant } from '../src/access-types.ts'
|
||||
import type { CuaAppHandle } from '../src/types.ts'
|
||||
import { NativeClient } from '../src/native-client.ts'
|
||||
import { permissionFixture } from './permission-fixture.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ instances: [] as Array<{
|
||||
disposed: boolean
|
||||
@@ -46,22 +47,18 @@ async function setup(choice = 'Allow for this session', options: { writable?: bo
|
||||
const owner = await ctx.agents.create({ sessionId: SessionId('cua-access-test') })
|
||||
const answer = vi.fn<() => Promise<AskUserQuestionAnswer>>(async () => ({ answers: [{ id: 'cua-app-access', selected: [choice] }] }))
|
||||
ctx.on('user-questions/request', answer)
|
||||
let grants: readonly CuaApplicationGrant[] = options.initial ?? []
|
||||
const save = vi.fn(async (next: readonly CuaApplicationGrant[]) => { grants = next })
|
||||
let writable = options.writable !== false
|
||||
const access = installCuaAccess(ctx, { native: nativeConfig, createChannel: unusedConnector, settingsNamespace: 'cua', grants: () => grants, get save() { return writable ? save : undefined } })
|
||||
const permissions = permissionFixture(ctx, options.initial, options.writable)
|
||||
const access = installCuaAccess(ctx, { native: nativeConfig, createChannel: unusedConnector, store: permissions.store })
|
||||
const client = new NativeClient(ctx, nativeConfig, unusedConnector)
|
||||
return { ctx, owner, agent: owner.agent, answer, save, access, client, grants: () => grants,
|
||||
setWritable(value: boolean) { writable = value },
|
||||
}
|
||||
return { ctx, owner, agent: owner.agent, answer, access, client, ...permissions }
|
||||
}
|
||||
|
||||
it('requires another user decision after the permission page permits asking again', async () => {
|
||||
const f = await setup('Deny')
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.ctx.cuaAccess.applications().blockedApps).toHaveLength(1)
|
||||
expect((await f.ctx.cuaAccess.applications()).blockedApps).toHaveLength(1)
|
||||
await f.ctx.cuaAccess.retryApproval(identity.id)
|
||||
expect(f.ctx.cuaAccess.applications().blockedApps).toEqual([])
|
||||
expect((await f.ctx.cuaAccess.applications()).blockedApps).toEqual([])
|
||||
await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED')
|
||||
f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })
|
||||
const retryClient = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
@@ -136,15 +133,14 @@ it('fails closed without a human answerer and never offers persistence without i
|
||||
await f.ctx.plugin(UserQuestions)
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
expect(f.ctx.cuaAccess.applications().persistentAvailable).toBe(false)
|
||||
expect((await f.ctx.cuaAccess.applications()).persistentAvailable).toBe(false)
|
||||
})
|
||||
|
||||
it('projects task decisions, observes live config removal, and forgets a disposed task', async () => {
|
||||
const f = await setup('Always allow')
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
expect(f.ctx.cuaAccess.applications().allowedApps).toHaveLength(1)
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toHaveLength(1)
|
||||
await f.save([])
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REVOKED')
|
||||
await f.owner.dispose()
|
||||
f.access.forget(f.agent)
|
||||
@@ -160,8 +156,8 @@ it('refuses to promise revocation of a saved decision without persistence', asyn
|
||||
it('checks native permissions without converting an OS request into a grant', async () => {
|
||||
const f = await setup()
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const before = f.ctx.cuaAccess.applications()
|
||||
expect(before).toEqual({ settingsNamespace: 'cua', allowedApps: [], blockedApps: [], persistentAvailable: true })
|
||||
const before = (await f.ctx.cuaAccess.applications())
|
||||
expect(before).toEqual({ allowedApps: [], blockedApps: [], persistentAvailable: true })
|
||||
expect(await f.ctx.cuaAccess.systemStatus()).toMatchObject({ accessibility: false, screenRecording: false })
|
||||
expect(await f.ctx.cuaAccess.requestPermission('accessibility')).toMatchObject({ accessibility: false })
|
||||
await f.ctx.cuaAccess.openSettings('screenRecording')
|
||||
@@ -205,7 +201,6 @@ it('isolates other tasks and applications while revoking and forgetting one deci
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await f.access.select(f.agent, f.client, otherHandle, other, signal)
|
||||
await f.access.select(owner.agent, otherClient, otherHandle, other, signal)
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
await f.ctx.cuaAccess.retryApproval(identity.id)
|
||||
const started: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const release: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
@@ -233,7 +228,7 @@ it('removes a grant whose save is already pending when another task revokes the
|
||||
const release: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
onTestFinished(() => { release.resolve() })
|
||||
const save = f.save.getMockImplementation()!
|
||||
f.save.mockImplementationOnce(async (grants) => { entered.resolve(); await release.promise; await save(grants) })
|
||||
f.save.mockImplementationOnce(async (...args) => { entered.resolve(); await release.promise; await save(...args) })
|
||||
f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] })
|
||||
const selection = f.access.select(owner.agent, client, handle, identity, signal)
|
||||
const settledSelection = Promise.allSettled([selection])
|
||||
@@ -271,10 +266,10 @@ it('projects restored persistent grants without showing the same application as
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
const { persistable: _persistable, ...grant } = identity
|
||||
await f.save([grant])
|
||||
expect(f.ctx.cuaAccess.applications().blockedApps).toEqual([])
|
||||
expect((await f.ctx.cuaAccess.applications()).blockedApps).toEqual([])
|
||||
const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await f.access.select(f.agent, replacement, handle, identity, signal)
|
||||
expect(f.ctx.cuaAccess.applications().allowedApps).toEqual([grant])
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toEqual([grant])
|
||||
expect(f.answer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -315,7 +310,7 @@ it('keeps other session grants after revocation while requiring fresh native han
|
||||
await f.access.select(f.agent, f.client, otherHandle, other, signal)
|
||||
await f.ctx.cuaAccess.revoke(identity.id)
|
||||
await expect(f.access.run(f.client, otherHandle, signal, async () => 'old handle')).rejects.toThrow('NATIVE_CLOSED')
|
||||
const state = f.ctx.cuaAccess.applications()
|
||||
const state = (await f.ctx.cuaAccess.applications())
|
||||
expect(state.blockedApps.map(app => app.id)).toEqual([identity.id])
|
||||
const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await f.access.select(f.agent, replacement, otherHandle, other, signal)
|
||||
@@ -333,15 +328,26 @@ it('does not let a stale revoked task invalidate another task after a saved gran
|
||||
const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await f.access.select(owner.agent, replacement, handle, identity, signal)
|
||||
await expect(f.access.run(f.client, handle, signal, async () => 'stale')).rejects.toThrow('APP_ACCESS_REVOKED')
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
expect(replacement.disposed).toBe(false)
|
||||
await expect(f.access.run(replacement, handle, signal, async () => 'restored')).resolves.toBe('restored')
|
||||
})
|
||||
|
||||
it('offers fixed approval actions with native metadata while keeping icons out of saved grants', async () => {
|
||||
const f = await setup('Always allow')
|
||||
const ask = vi.spyOn(f.ctx.userQuestions, 'ask')
|
||||
const icon = 'data:image/png;base64,aWNvbg=='
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal, icon)
|
||||
expect(ask.mock.calls[0]?.[0].questions[0]?.intent).toMatchObject({
|
||||
kind: 'approval', approve: 'Allow for this session', reject: 'Deny', always: 'Always allow',
|
||||
subject: { name: identity.name, icon },
|
||||
})
|
||||
expect(f.grants()).toEqual([{ id: identity.id, name: identity.name, executablePath: identity.executablePath }])
|
||||
})
|
||||
|
||||
it('reads application decisions without native work and retrieves icons separately', async () => {
|
||||
const f = await setup('Always allow')
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
expect(f.ctx.cuaAccess.applications().allowedApps).toEqual(f.grants())
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toEqual(f.grants())
|
||||
expect(native.instances).toHaveLength(1)
|
||||
await f.ctx.cuaAccess.systemStatus()
|
||||
const control = native.instances.at(-1)!
|
||||
@@ -349,7 +355,7 @@ it('reads application decisions without native work and retrieves icons separate
|
||||
onTestFinished(() => { pending.resolve({}) })
|
||||
control.request.mockReturnValueOnce(pending.promise)
|
||||
const icons = f.ctx.cuaAccess.applicationIcons()
|
||||
expect(f.ctx.cuaAccess.applications().allowedApps).toEqual(f.grants())
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toEqual(f.grants())
|
||||
pending.resolve({ [identity.executablePath]: 'data:image/png;base64,aWNvbg==' })
|
||||
expect(await icons).toEqual({ [identity.executablePath]: 'data:image/png;base64,aWNvbg==' })
|
||||
expect(f.grants()[0]).not.toHaveProperty('icon')
|
||||
@@ -366,7 +372,7 @@ it('skips native icon work for an empty application list and propagates icon fai
|
||||
await f.ctx.cuaAccess.systemStatus()
|
||||
native.instances.at(-1)!.request.mockRejectedValueOnce(new Error('icons unavailable'))
|
||||
await expect(f.ctx.cuaAccess.applicationIcons()).rejects.toThrow('icons unavailable')
|
||||
expect(f.ctx.cuaAccess.applications().allowedApps).toHaveLength(1)
|
||||
expect((await f.ctx.cuaAccess.applications()).allowedApps).toHaveLength(1)
|
||||
expect((await f.ctx.cuaAccess.systemStatus()).accessibility).toBe(false)
|
||||
})
|
||||
|
||||
@@ -386,12 +392,12 @@ it('omits session-only grants from the permission page and retains their access
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const other = { ...identity, id: 'b'.repeat(64) as typeof identity.id, name: 'Another application', executablePath: '/Applications/Other.app' }
|
||||
await f.access.select(f.agent, f.client, 'other-application' as CuaAppHandle, other, signal)
|
||||
expect(f.ctx.cuaAccess.applications()).toEqual({ settingsNamespace: 'cua', allowedApps: [], blockedApps: [], persistentAvailable: true })
|
||||
expect((await f.ctx.cuaAccess.applications())).toEqual({ allowedApps: [], blockedApps: [], persistentAvailable: true })
|
||||
const owner = await f.ctx.agents.create({ sessionId: SessionId('cua-always-allow') })
|
||||
const client = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] })
|
||||
await f.access.select(owner.agent, client, handle, identity, signal)
|
||||
const state = f.ctx.cuaAccess.applications()
|
||||
const state = (await f.ctx.cuaAccess.applications())
|
||||
expect(state.allowedApps.map(app => app.id)).toEqual([identity.id])
|
||||
expect(state.blockedApps).toEqual([])
|
||||
await expect(f.access.run(f.client, handle, signal, async () => 'still allowed')).resolves.toBe('still allowed')
|
||||
@@ -417,3 +423,24 @@ it('configures persistent PTC through the existing profile editor only on explic
|
||||
await f.ctx.cuaAccess.enablePersistentPtc()
|
||||
expect(edit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects invalid application identities before a permission file or native handle can be touched', async () => {
|
||||
const f = await setup()
|
||||
const invalid = '../another-profile' as import('../src/application-types.ts').CuaApplicationId
|
||||
await expect(f.ctx.cuaAccess.revoke(invalid)).rejects.toThrow()
|
||||
await expect(f.ctx.cuaAccess.retryApproval(invalid)).rejects.toThrow()
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
expect(f.client.disposed).toBe(false)
|
||||
})
|
||||
|
||||
it('does not reactivate old handles when stored permission was revoked and later restored', async () => {
|
||||
const f = await setup('Always allow')
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await f.save([])
|
||||
await f.save([{ id: identity.id, name: identity.name, executablePath: identity.executablePath }])
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.client.disposed).toBe(true)
|
||||
const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await f.access.select(f.agent, replacement, handle, identity, signal)
|
||||
expect(f.answer).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
/** Application decisions are checked at dispatch and revocation cancels owned work. */
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import * as JsonStorage from '@deepseek-ai/dsh-storage-json'
|
||||
import * as Domains from '@deepseek-ai/dsh-storage-domain'
|
||||
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import UserQuestions, { UserQuestionError, type AskUserQuestionAnswer, type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions'
|
||||
@@ -9,6 +14,8 @@ import { ApplicationAuthorizationResult, ApplicationAccess } from '../src/applic
|
||||
import type { CuaApplicationGrant } from '../src/application-types.ts'
|
||||
import type { CuaAppHandle } from '../src/types.ts'
|
||||
import { NativeClient } from '../src/native-client.ts'
|
||||
import { permissionFixture } from './permission-fixture.ts'
|
||||
import { CuaPermissionStore } from '../src/permission-store.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ instances: [] as Array<{
|
||||
disposed: boolean
|
||||
@@ -46,14 +53,10 @@ async function setup(choice = 'Allow for this session', options: { writable?: bo
|
||||
const owner = await ctx.agents.create({ sessionId: SessionId('cua-access-test') })
|
||||
const answer = vi.fn<(request: AskUserQuestionRequest) => Promise<AskUserQuestionAnswer>>(async () => ({ answers: [{ id: 'cua-app-access', selected: [choice] }] }))
|
||||
ctx.on('user-questions/request', answer)
|
||||
let grants: readonly CuaApplicationGrant[] = options.initial ?? []
|
||||
const save = vi.fn(async (next: readonly CuaApplicationGrant[]) => { grants = next })
|
||||
let writable = options.writable !== false
|
||||
const access = new ApplicationAccess(ctx, { grants: () => grants, get save() { return writable ? save : undefined } })
|
||||
const permissions = permissionFixture(ctx, options.initial, options.writable)
|
||||
const access = new ApplicationAccess(ctx, { store: permissions.store })
|
||||
const client = new NativeClient(ctx, nativeConfig, unusedConnector)
|
||||
return { ctx, owner, agent: owner.agent, answer, save, access, client, grants: () => grants,
|
||||
setWritable(value: boolean) { writable = value },
|
||||
}
|
||||
return { ctx, owner, agent: owner.agent, answer, access, client, ...permissions }
|
||||
}
|
||||
|
||||
it('refuses raw handle dispatch until getApp obtains consent', async () => {
|
||||
@@ -76,15 +79,15 @@ it.each([
|
||||
expect(question).toMatchObject({
|
||||
id: 'cua-app-access',
|
||||
question: 'Allow computer use to launch if needed, read, capture, and control Test application?',
|
||||
detail: 'Session access lasts until DSH restarts or computer use is disabled.',
|
||||
detail: 'Session access applies only to this conversation.',
|
||||
intent: { kind: 'approval', approve: 'Allow for this session', reject: 'Deny',
|
||||
subject: { name: 'Test application' }, display: {
|
||||
question: { en: 'Allow computer use to launch, read, capture and control this application?',
|
||||
zh: '允许 Computer Use 启动、读取、截图和控制此应用吗?',
|
||||
ja: 'Computer Use によるこのアプリの起動、読み取り、画面キャプチャ、操作を許可しますか?' },
|
||||
detail: { en: 'Session access lasts until DSH restarts or computer use is disabled.',
|
||||
zh: '会话授权在 DSH 重启或关闭 Computer Use 后失效。',
|
||||
ja: 'セッションの許可は DSH の再起動または Computer Use の無効化まで有効です。' },
|
||||
detail: { en: 'Session access applies only to this conversation.',
|
||||
zh: '会话授权仅在当前对话中生效。',
|
||||
ja: 'セッションの許可はこの会話にのみ適用されます。' },
|
||||
approve: { en: 'Allow for this session', zh: '本会话允许', ja: 'このセッションで許可',
|
||||
es: 'Permitir durante esta sesión', fr: 'Autoriser pour cette session', de: 'Für diese Sitzung zulassen',
|
||||
ru: 'Разрешить для этого сеанса', pt: 'Permitir nesta sessão' },
|
||||
@@ -101,7 +104,7 @@ it.each([
|
||||
expect(f.grants()).toEqual([])
|
||||
})
|
||||
|
||||
it('reuses session consent across application handles and connections without saving it', async () => {
|
||||
it('reuses session consent across application handles and connections without granting global access', async () => {
|
||||
const f = await setup()
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await expect(f.access.run(f.client, handle, signal, async () => 'read')).resolves.toBe('read')
|
||||
@@ -195,24 +198,14 @@ it('merges a concurrently saved decision using the latest grant list', async ()
|
||||
expect(f.grants()).toEqual([{ id: identity.id, name: identity.name, executablePath: identity.executablePath }])
|
||||
})
|
||||
|
||||
it('refuses queued persistence when the profile writer disappears', async () => {
|
||||
it('rejects a long-term choice when storage becomes unavailable during the question', async () => {
|
||||
const f = await setup('Always allow')
|
||||
const saving: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const release: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
onTestFinished(() => { release.resolve() })
|
||||
f.save.mockImplementationOnce(async () => { saving.resolve(); await release.promise })
|
||||
const first = f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await saving.promise
|
||||
const other = { ...identity, id: 'b'.repeat(64) as typeof identity.id }
|
||||
const second = f.access.select(f.agent, f.client, 'second' as CuaAppHandle, other, signal)
|
||||
const refused = expect(second).rejects.toThrow('cannot be saved')
|
||||
await vi.waitFor(() => { expect(f.answer).toHaveBeenCalledTimes(2) })
|
||||
await setImmediate()
|
||||
f.setWritable(false)
|
||||
release.resolve()
|
||||
await first
|
||||
await refused
|
||||
expect(f.save).toHaveBeenCalledOnce()
|
||||
f.answer.mockImplementationOnce(async () => {
|
||||
f.setWritable(false)
|
||||
return { answers: [{ id: 'cua-app-access', selected: ['Always allow'] }] }
|
||||
})
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serializes duplicate application questions and rejects every waiting selection after denial', async () => {
|
||||
@@ -267,7 +260,7 @@ it('keeps another task allowed when the same application is denied in a new task
|
||||
await expect(f.access.run(f.client, handle, signal, async () => 'still allowed')).resolves.toBe('still allowed')
|
||||
})
|
||||
|
||||
it('enforces a removed saved grant before its configuration notification arrives', async () => {
|
||||
it('enforces a removed saved grant from fresh storage before dispatch', async () => {
|
||||
const f = await setup('Always allow')
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await f.save([])
|
||||
@@ -277,7 +270,7 @@ it('enforces a removed saved grant before its configuration notification arrives
|
||||
expect(f.client.disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels native work when saved consent is removed from configuration', async () => {
|
||||
it('cancels native work when saved consent is revoked', async () => {
|
||||
const f = await setup('Always allow')
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
@@ -289,9 +282,7 @@ it('cancels native work when saved consent is removed from configuration', async
|
||||
})
|
||||
const rejected = expect(pending).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
await entered.promise
|
||||
await f.save([])
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
await f.access.revoke(identity.id)
|
||||
await rejected
|
||||
expect(f.client.disposed).toBe(true)
|
||||
await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REVOKED')
|
||||
@@ -332,7 +323,6 @@ it('refuses closed helpers and forgets one task without removing another task',
|
||||
const owner = await f.ctx.agents.create({ sessionId: SessionId('other-consented-task') })
|
||||
const other = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await f.access.select(owner.agent, other, handle, identity, signal)
|
||||
f.ctx.emit('loader/volatile-update', [['allowedApps']])
|
||||
f.access.forget(f.agent)
|
||||
await expect(f.access.run(other, handle, signal, async () => 'kept')).resolves.toBe('kept')
|
||||
other.invalidate()
|
||||
@@ -400,17 +390,132 @@ it('revokes session consent even after its agent and connection are released', a
|
||||
const replacement = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await expect(f.access.select(f.agent, replacement, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.answer).toHaveBeenCalledOnce()
|
||||
f.access.retryApproval(identity.id)
|
||||
await f.access.retryApproval(identity.id)
|
||||
await f.access.select(f.agent, replacement, handle, identity, signal)
|
||||
expect(f.answer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears session consent when the provider is disabled and recreated', async () => {
|
||||
it('retains conversation consent when the provider is recreated over the same store', async () => {
|
||||
const f = await setup()
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await f.access.dispose()
|
||||
const access = new ApplicationAccess(f.ctx, { grants: f.grants, save: f.save })
|
||||
const access = new ApplicationAccess(f.ctx, { store: f.store })
|
||||
const client = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
await access.select(f.agent, client, handle, identity, signal)
|
||||
expect(f.answer).toHaveBeenCalledTimes(2)
|
||||
expect(f.answer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores consent after a complete context restart only for the original conversation', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'cua-conversation-consent-'))
|
||||
const directory = join(root, 'profile')
|
||||
await mkdir(directory)
|
||||
const contexts: Context[] = []
|
||||
onTestFinished(async () => {
|
||||
for (const ctx of contexts) await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
const open = async () => {
|
||||
const f = await setup()
|
||||
contexts.push(f.ctx)
|
||||
f.ctx.provide('profileContext', { dir: directory })
|
||||
await f.ctx.plugin(Storage)
|
||||
await f.ctx.plugin(JsonStorage, { root: join(root, 'storages') })
|
||||
await f.ctx.plugin(Domains, { backend: 'json' })
|
||||
const access = new ApplicationAccess(f.ctx, { store: new CuaPermissionStore(f.ctx, [], 1000) })
|
||||
return { ...f, access }
|
||||
}
|
||||
const first = await open()
|
||||
await first.access.select(first.agent, first.client, handle, identity, signal)
|
||||
expect(first.answer).toHaveBeenCalledOnce()
|
||||
await first.ctx.fiber.dispose()
|
||||
const restored = await open()
|
||||
await restored.access.select(restored.agent, restored.client, handle, identity, signal)
|
||||
expect(restored.answer).not.toHaveBeenCalled()
|
||||
await expect(restored.access.run(restored.client, handle, signal, async () => 'read')).resolves.toBe('read')
|
||||
const other = await restored.ctx.agents.create({ sessionId: SessionId('another-conversation') })
|
||||
const client = new NativeClient(restored.ctx, nativeConfig, unusedConnector)
|
||||
await restored.access.select(other.agent, client, handle, identity, signal)
|
||||
expect(restored.answer).toHaveBeenCalledOnce()
|
||||
expect((await restored.access.status()).allowedApps).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
it('revokes an unselected application and clears only refused conversations', async () => {
|
||||
const f = await setup()
|
||||
await f.access.revoke(identity.id)
|
||||
await f.access.retryApproval(identity.id)
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const owner = await f.ctx.agents.create({ sessionId: SessionId('refused-conversation') })
|
||||
const client = new NativeClient(f.ctx, nativeConfig, unusedConnector)
|
||||
f.answer.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Deny'] }] })
|
||||
await expect(f.access.select(owner.agent, client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
await f.access.retryApproval(identity.id)
|
||||
await expect(f.access.run(f.client, handle, signal, async () => 'still allowed')).resolves.toBe('still allowed')
|
||||
await f.access.select(owner.agent, new NativeClient(f.ctx, nativeConfig, unusedConnector), handle, identity, signal)
|
||||
expect(f.answer).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['revoked', 'session revision'])('rejects a delayed answer after another store writer changes %s', async (change) => {
|
||||
const f = await setup()
|
||||
const answer = Promise.withResolvers<AskUserQuestionAnswer>()
|
||||
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
onTestFinished(() => { answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Deny'] }] }) })
|
||||
f.answer.mockImplementationOnce(() => { entered.resolve(); return answer.promise })
|
||||
const pending = f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const rejected = expect(pending).rejects.toThrow('APP_ACCESS_REVOKED')
|
||||
await entered.promise
|
||||
const { persistable: _persistable, ...grant } = identity
|
||||
await f.store.withState(signal, transaction => transaction.save({ migrated: true, applications: {
|
||||
[identity.id]: { revoked: change === 'revoked' ? 1 : 0, sessions: change === 'session revision' ? {
|
||||
[f.agent.id]: { identity: grant, decision: 'deny', revision: 1 },
|
||||
} : {} },
|
||||
} }))
|
||||
answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })
|
||||
await rejected
|
||||
const dispatch = vi.fn(async () => null)
|
||||
await expect(f.access.run(f.client, handle, signal, dispatch)).rejects.toThrow('APP_ACCESS_REQUIRED')
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not store a cancellation decision after provider disposal starts', async () => {
|
||||
const f = await setup()
|
||||
const answer = Promise.withResolvers<AskUserQuestionAnswer>()
|
||||
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
onTestFinished(() => { answer.resolve({ answers: [{ id: 'cua-app-access', selected: ['Deny'] }] }) })
|
||||
f.answer.mockImplementationOnce(() => { entered.resolve(); return answer.promise })
|
||||
const pending = f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
const rejected = expect(pending).rejects.toThrow()
|
||||
await entered.promise
|
||||
const closing = f.access.dispose()
|
||||
answer.reject(new UserQuestionError('provider stopped', 'ASK_CANCELLED'))
|
||||
await rejected; await closing
|
||||
expect(await f.store.withState(signal, transaction => transaction.state.applications)).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects persistent consent when storage capability disappears before the commit', async () => {
|
||||
const f = await setup('Always allow')
|
||||
const original = f.withState.getMockImplementation()!
|
||||
f.withState.mockImplementationOnce(original).mockImplementationOnce((signal, operation) => {
|
||||
f.setWritable(false)
|
||||
return original(signal, operation)
|
||||
})
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('APP_ACCESS_DENIED')
|
||||
expect(f.grants()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not attach a native handle that closes while its durable grant is committing', async () => {
|
||||
const f = await setup('Always allow')
|
||||
const save = f.save.getMockImplementation()!
|
||||
f.save.mockImplementationOnce(async (...args) => { await save(...args); f.client.invalidate() })
|
||||
await expect(f.access.select(f.agent, f.client, handle, identity, signal)).rejects.toThrow('NATIVE_CLOSED')
|
||||
await expect(f.access.run(f.client, handle, signal, async () => null)).rejects.toThrow('APP_ACCESS_REQUIRED')
|
||||
})
|
||||
|
||||
it('denies existing handles when the stored application record is removed', async () => {
|
||||
const f = await setup()
|
||||
await f.access.select(f.agent, f.client, handle, identity, signal)
|
||||
await f.store.withState(signal, transaction => transaction.save({ migrated: true, applications: {} }))
|
||||
const dispatch = vi.fn(async () => null)
|
||||
await expect(f.access.run(f.client, handle, signal, dispatch)).rejects.toThrow('APP_ACCESS_REVOKED')
|
||||
expect(dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import { PermissionController } from '../src/client/controller.ts'
|
||||
|
||||
const value: CuaSystemPermissionState = { accessibility: true, screenRecording: false, helper: { pid: 9 }, parent: { pid: 8 } }
|
||||
const applications: CuaApplicationState = {
|
||||
allowedApps: [], blockedApps: [], settingsNamespace: 'cua', persistentAvailable: true }
|
||||
allowedApps: [], blockedApps: [], persistentAvailable: true }
|
||||
type CuaRemote = Context['remote']['cuaAccess']
|
||||
function fixture() {
|
||||
const actions = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** External file writer used to verify that an open provider reloads durable authorization. */
|
||||
import { readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import type { PermissionState } from '../../src/permission-store.ts'
|
||||
import type { CuaApplicationId } from '../../src/application-types.ts'
|
||||
|
||||
const [file, id] = process.argv.slice(2)
|
||||
if (!file || !id) throw new Error('Expected storage file and application id')
|
||||
const record = JSON.parse(await readFile(file, 'utf8')) as { global: PermissionState }
|
||||
const existing = record.global.applications[id]
|
||||
record.global.applications[id] = {
|
||||
revoked: (existing?.revoked ?? 0) + 1,
|
||||
sessions: {},
|
||||
always: { id: id as CuaApplicationId, name: 'Other process', executablePath: '/other' },
|
||||
}
|
||||
const temporary = `${file}.writer-${process.pid}`
|
||||
await writeFile(temporary, JSON.stringify(record))
|
||||
await rename(temporary, file)
|
||||
process.stdout.write('saved\n')
|
||||
@@ -15,7 +15,7 @@ import type { ApplicationApprovalProps } from '../src/client/ApplicationApproval
|
||||
const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-cua', descriptors: [] }
|
||||
const value: CuaApplicationState & { system: CuaSystemPermissionState } = {
|
||||
system: { accessibility: false, screenRecording: false, helper: { pid: 2 }, parent: { pid: 1 } },
|
||||
allowedApps: [], blockedApps: [], settingsNamespace: 'cua', persistentAvailable: true }
|
||||
allowedApps: [], blockedApps: [], persistentAvailable: true }
|
||||
|
||||
function actionsOf(value: Record<string, unknown>): asserts value is Record<string, unknown> & PermissionFace {
|
||||
assert(typeof value.refresh === 'function' && typeof value.request === 'function' && typeof value.openSettings === 'function')
|
||||
@@ -25,8 +25,7 @@ function actionsOf(value: Record<string, unknown>): asserts value is Record<stri
|
||||
}
|
||||
|
||||
async function fixture(fail = false) {
|
||||
const ctx = new Context(), unmount = vi.fn(async () => {}), unsubscribe = vi.fn()
|
||||
let settingsListener: ((namespace: string, revision: number) => void) | undefined
|
||||
const ctx = new Context(), unmount = vi.fn(async () => {})
|
||||
class Remote extends Service {
|
||||
constructor() { super(ctx, 'remote') }
|
||||
async $mount(received: TypertRemoteContribution) {
|
||||
@@ -34,7 +33,6 @@ async function fixture(fail = false) {
|
||||
const owned = this.ctx.effect(() => unmount)
|
||||
return () => owned()
|
||||
}
|
||||
$on(_event: string, listener: (namespace: string, revision: number) => void) { settingsListener = listener; return unsubscribe }
|
||||
}
|
||||
new Remote()
|
||||
const changes = createSnapshotStore(false)
|
||||
@@ -66,14 +64,13 @@ async function fixture(fail = false) {
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
await ctx.plugin(SlotRegistry)
|
||||
ctx.slots.register({ name: 'root', children: {
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
} } as never, () => null)
|
||||
if (fail) vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot unavailable') })
|
||||
return { ctx, changes, unmount, unsubscribe, status, systemStatus, applicationIcons,
|
||||
return { ctx, changes, unmount, status, systemStatus, applicationIcons,
|
||||
requestPermission, openSettings, revoke, retryApproval, setDefault, setPersistent, runtimeConfiguration,
|
||||
updateSettings(namespace: string) { settingsListener!(namespace, 1) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +140,6 @@ it('withdraws the permission Remote and all controls with the optional plugin',
|
||||
expect(f.ctx.slots.entries('conversation.composer')).toHaveLength(0)
|
||||
expect(f.ctx.slots.entries('plugins.bundle.config')).toHaveLength(0)
|
||||
expect(f.ctx.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
expect(f.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(f.unmount).toHaveBeenCalledOnce()
|
||||
} finally { await f.ctx.fiber.dispose() }
|
||||
})
|
||||
@@ -183,23 +179,12 @@ it.each(['plugin', 'root'])('keeps the Remote mounted until an accepted revoke c
|
||||
}
|
||||
})
|
||||
|
||||
it('refreshes only the settings namespace reported by the permission owner', async () => {
|
||||
it('refreshes application storage on reconnect', async () => {
|
||||
const f = await fixture()
|
||||
f.status.mockResolvedValue({ ok: true, value: { ...value, settingsNamespace: 'custom-cua-entry' } })
|
||||
try {
|
||||
await f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) })
|
||||
const actions = f.ctx.slots.entries('plugins.bundle.config')[0]!.inject!()
|
||||
actionsOf(actions)
|
||||
f.updateSettings('locale')
|
||||
expect(f.status).not.toHaveBeenCalled()
|
||||
actions.refresh()
|
||||
await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().busy).toBe(false) })
|
||||
expect(f.status).toHaveBeenCalledOnce()
|
||||
f.updateSettings('cua')
|
||||
f.updateSettings('locale')
|
||||
expect(f.status).toHaveBeenCalledOnce()
|
||||
f.updateSettings('custom-cua-entry')
|
||||
await vi.waitFor(() => { expect(f.status).toHaveBeenCalledTimes(2) })
|
||||
f.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(f.status).toHaveBeenCalledOnce() })
|
||||
} finally { await f.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { en, zh } from '../src/client/locales.ts'
|
||||
afterEach(() => { cleanup(); vi.useRealTimers() })
|
||||
const value: CuaApplicationState & { system: CuaSystemPermissionState } = { system: { accessibility: false, screenRecording: true,
|
||||
helper: { pid: 9, path: '/native/helper' }, parent: { pid: 8, path: '/host/node' } },
|
||||
allowedApps: [], blockedApps: [], settingsNamespace: 'cua', persistentAvailable: true }
|
||||
allowedApps: [], blockedApps: [], persistentAvailable: true }
|
||||
|
||||
function observe(value: CuaApplicationState & { system: CuaSystemPermissionState }) {
|
||||
return { system: { value: value.system, busy: false, error: null },
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Policy tests use the real store's in-memory composition and injectable durable-write failures. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { vi } from 'vitest'
|
||||
import { CuaPermissionStore, type PermissionState } from '../src/permission-store.ts'
|
||||
import type { CuaApplicationGrant } from '../src/application-types.ts'
|
||||
|
||||
/**
|
||||
* @param ctx Test context.
|
||||
* @param initial Existing long-term grants.
|
||||
* @param writable Whether long-term decisions can be saved.
|
||||
* @returns Store and external writer controls.
|
||||
*/
|
||||
export function permissionFixture(ctx: Context, initial: readonly CuaApplicationGrant[] = [], writable = true) {
|
||||
const store = new CuaPermissionStore(ctx, initial, 1000)
|
||||
const capability = vi.spyOn(store, 'persistent', 'get').mockImplementation(() => writable)
|
||||
const original = store.withState.bind(store)
|
||||
const grantsOf = (state: PermissionState) => Object.values(state.applications).flatMap(app => app.always ? [app.always] : [])
|
||||
let observed = [...initial]
|
||||
const save = vi.fn(async (grants: readonly CuaApplicationGrant[], commit?: () => Promise<void>) => {
|
||||
if (commit) { await commit(); return }
|
||||
await original(new AbortController().signal, async (transaction) => {
|
||||
const applications = { ...transaction.state.applications }
|
||||
for (const [id, app] of Object.entries(applications)) if (app.always) {
|
||||
const { always: _always, ...rest } = app
|
||||
applications[id] = { ...rest, revoked: app.revoked + 1 }
|
||||
}
|
||||
for (const grant of grants) applications[grant.id] = {
|
||||
...(applications[grant.id] ?? { revoked: 0, sessions: {} }), always: grant,
|
||||
}
|
||||
await transaction.save({ ...transaction.state, applications })
|
||||
observed = [...grants]
|
||||
})
|
||||
})
|
||||
const withState = vi.spyOn(store, 'withState').mockImplementation((signal, operation) => original(signal, async (transaction) => {
|
||||
observed = grantsOf(transaction.state)
|
||||
return operation({ ...transaction, save: async (next) => {
|
||||
const nextGrants = grantsOf(next)
|
||||
const commit = async () => { await transaction.save(next); observed = nextGrants }
|
||||
if (isDeepStrictEqual(observed, nextGrants)) await commit()
|
||||
else await save(nextGrants, commit)
|
||||
} })
|
||||
}))
|
||||
return { store, save, withState, grants: () => observed,
|
||||
setWritable(value: boolean) { writable = value; capability.mockImplementation(() => writable) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/** Durable profile and conversation decisions use the production storage composition. */
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, symlink } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import * as JsonStorage from '@deepseek-ai/dsh-storage-json'
|
||||
import * as Domains from '@deepseek-ai/dsh-storage-domain'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { CuaPermissionStore } from '../src/permission-store.ts'
|
||||
import type { CuaApplicationGrant, CuaApplicationId } from '../src/application-types.ts'
|
||||
|
||||
const signal = new AbortController().signal
|
||||
const grant = { id: 'a'.repeat(64) as CuaApplicationId, name: 'Fixture', executablePath: '/fixture/app' }
|
||||
|
||||
async function setup() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'cua-permission-store-'))
|
||||
const storageRoot = join(root, 'storages'), directory = join(root, 'profile')
|
||||
await mkdir(directory)
|
||||
const contexts: Context[] = []
|
||||
onTestFinished(async () => { for (const ctx of contexts) await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
|
||||
const open = async (profile = directory, legacy: readonly CuaApplicationGrant[] = []) => {
|
||||
const ctx = new Context(); contexts.push(ctx)
|
||||
ctx.provide('profileContext', { dir: profile })
|
||||
await ctx.plugin(Storage)
|
||||
await ctx.plugin(JsonStorage, { root: storageRoot })
|
||||
const domain = await ctx.plugin(Domains, { backend: 'json' })
|
||||
return { ctx, domain, store: new CuaPermissionStore(ctx, legacy, 30_000) }
|
||||
}
|
||||
return { root, storageRoot, directory, open }
|
||||
}
|
||||
|
||||
it('imports legacy grants once, retains conversation choices, and never restores a revoked legacy grant', async () => {
|
||||
const f = await setup(), first = await f.open(f.directory, [grant])
|
||||
await first.store.withState(signal, async (transaction) => {
|
||||
expect(transaction.state.applications[grant.id]?.always).toEqual(grant)
|
||||
await transaction.save({ migrated: true, applications: { [grant.id]: { revoked: 1, sessions: {
|
||||
conversation: { identity: grant, decision: 'allow', revision: 1 },
|
||||
refused: { identity: grant, decision: 'deny', revision: 2 },
|
||||
} } } })
|
||||
})
|
||||
await first.ctx.fiber.dispose()
|
||||
const resumed = await f.open(f.directory, [grant])
|
||||
const stored = await resumed.store.withState(signal, transaction => transaction.state)
|
||||
expect(stored.applications[grant.id]?.always).toBeUndefined()
|
||||
expect(stored.applications[grant.id]?.sessions.conversation?.decision).toBe('allow')
|
||||
expect(stored.applications[grant.id]?.sessions.refused?.decision).toBe('deny')
|
||||
expect(stored.applications[grant.id]?.sessions.another).toBeUndefined()
|
||||
const files = await readdir(f.storageRoot)
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]).toMatch(/^cua_permissions_[a-f0-9]{64}\.json$/)
|
||||
expect(await readFile(join(f.storageRoot, files[0]!), 'utf8')).toContain('conversation')
|
||||
})
|
||||
|
||||
it('isolates different profiles that use the same storage root', async () => {
|
||||
const f = await setup(), a = await f.open(f.directory, [grant])
|
||||
await a.store.withState(signal, transaction => transaction.state)
|
||||
const other = join(f.root, 'other'); await mkdir(other)
|
||||
const b = await f.open(other)
|
||||
expect(await b.store.withState(signal, transaction => transaction.state.applications)).toEqual({})
|
||||
expect(await readdir(f.storageRoot)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('uses the same profile identity through a directory symlink', async () => {
|
||||
const f = await setup(), a = await f.open(f.directory, [grant])
|
||||
await a.store.withState(signal, transaction => transaction.state)
|
||||
const alias = join(f.root, 'alias'); await symlink(f.directory, alias)
|
||||
const b = await f.open(alias)
|
||||
expect(await b.store.withState(signal, transaction => transaction.state.applications[grant.id]?.always)).toEqual(grant)
|
||||
expect(await readdir(f.storageRoot)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reloads another process’s writes instead of reusing an in-memory authorization snapshot', async () => {
|
||||
const f = await setup(), a = await f.open(f.directory, [grant])
|
||||
await a.store.withState(signal, transaction => transaction.state)
|
||||
const controller = new AbortController()
|
||||
const file = join(f.storageRoot, (await readdir(f.storageRoot))[0]!)
|
||||
const running = promisify(execFile)(process.execPath, [
|
||||
fileURLToPath(new URL('./fixtures/permission-store-worker.ts', import.meta.url)), file, grant.id,
|
||||
], { cwd: process.cwd(), env: { PATH: process.env.PATH }, signal: controller.signal })
|
||||
onTestFinished(async () => { controller.abort(); await running.catch(() => {}) })
|
||||
expect((await running).stdout).toBe('saved\n')
|
||||
const latest = await a.store.withState(signal, transaction => transaction.state.applications[grant.id])
|
||||
expect(latest).toMatchObject({ revoked: 1, always: { name: 'Other process' } })
|
||||
})
|
||||
|
||||
it('serializes independent domain owners, cancels a waiting operation, and closes after a failure', async () => {
|
||||
const f = await setup(), a = await f.open(), b = await f.open()
|
||||
const entered = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => { release.resolve(undefined) })
|
||||
const holding = a.store.withState(signal, async (transaction) => {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return transaction.state
|
||||
})
|
||||
await entered.promise
|
||||
const controller = new AbortController(); controller.abort(new Error('cancelled'))
|
||||
await expect(b.store.withState(controller.signal, () => { throw new Error('must not enter') })).rejects.toThrow('cancelled')
|
||||
const next = b.store.withState(signal, async (transaction) => {
|
||||
await transaction.save({ migrated: true, applications: {} })
|
||||
throw new Error('operation failed after commit')
|
||||
})
|
||||
const failed = expect(next).rejects.toThrow('operation failed after commit')
|
||||
release.resolve(undefined); await holding; await failed
|
||||
expect(await a.store.withState(signal, transaction => transaction.state.migrated)).toBe(true)
|
||||
await Promise.all([a.store.drain(), b.store.drain()])
|
||||
})
|
||||
|
||||
it('rejects malformed or identity-mismatched data without replacing it with legacy grants', async () => {
|
||||
const f = await setup(), a = await f.open(f.directory, [grant])
|
||||
await a.store.withState(signal, transaction => transaction.state)
|
||||
const file = join(f.storageRoot, (await readdir(f.storageRoot))[0]!)
|
||||
const valid = await readFile(file, 'utf8')
|
||||
await writeFile(file, '{broken')
|
||||
await expect(a.store.withState(signal, transaction => transaction.state)).rejects.toThrow()
|
||||
expect(await readFile(file, 'utf8')).toBe('{broken')
|
||||
const altered = JSON.parse(valid) as { global: { applications: Record<string, { always: CuaApplicationGrant }> } }
|
||||
altered.global.applications[grant.id]!.always.id = 'b'.repeat(64) as CuaApplicationId
|
||||
const mismatch = JSON.stringify(altered); await writeFile(file, mismatch)
|
||||
await expect(a.store.withState(signal, transaction => transaction.state)).rejects.toMatchObject({ code: 'invalid-record' })
|
||||
expect(await readFile(file, 'utf8')).toBe(mismatch)
|
||||
})
|
||||
|
||||
it('uses temporary decisions only without a profile and fails when profile storage disappears', async () => {
|
||||
const ctx = new Context(); onTestFinished(() => ctx.fiber.dispose())
|
||||
const temporary = new CuaPermissionStore(ctx, [], 1000)
|
||||
expect(temporary.persistent).toBe(false)
|
||||
await temporary.withState(signal, transaction => transaction.save({ migrated: true, applications: {
|
||||
[grant.id]: { revoked: 0, sessions: { conversation: { identity: grant, decision: 'allow', revision: 1 } } },
|
||||
} }))
|
||||
expect(await temporary.withState(signal, transaction => transaction.state.applications[grant.id]?.sessions.conversation?.decision)).toBe('allow')
|
||||
const f = await setup(), a = await f.open()
|
||||
await a.domain.dispose()
|
||||
await expect(a.store.withState(signal, transaction => transaction.state)).rejects.toThrow('require storageDomain')
|
||||
})
|
||||
|
||||
it('keeps storage reads and other applications available while one native operation holds its lease', async () => {
|
||||
const f = await setup(), a = await f.open(), b = await f.open()
|
||||
const entered = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => { release.resolve(undefined) })
|
||||
const active = a.store.withApplication(grant.id, signal, async () => { entered.resolve(undefined); await release.promise })
|
||||
await entered.promise
|
||||
expect(await b.store.withState(signal, transaction => transaction.state.migrated)).toBe(true)
|
||||
expect(await b.store.withApplication('b'.repeat(64) as CuaApplicationId, signal, async () => 'independent')).toBe('independent')
|
||||
const waiting = new AbortController()
|
||||
const blocked = b.store.withApplication(grant.id, waiting.signal, async () => 'must not run')
|
||||
const rejected = expect(blocked).rejects.toThrow('cancelled')
|
||||
waiting.abort(new Error('cancelled'))
|
||||
await rejected
|
||||
release.resolve(undefined); await active
|
||||
await expect(b.store.withApplication(grant.id, signal, async () => { throw new Error('dispatch failed') })).rejects.toThrow('dispatch failed')
|
||||
expect(await a.store.withApplication(grant.id, signal, async () => 'released')).toBe('released')
|
||||
})
|
||||
@@ -45,9 +45,6 @@
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-questions"
|
||||
},
|
||||
@@ -65,6 +62,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-preset-registry"
|
||||
},
|
||||
{
|
||||
"path": "../../storage/storage-domain"
|
||||
},
|
||||
{
|
||||
"path": "../../boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../boot/config-editor"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -972,10 +972,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'Completion after the shared runtime reload; already-persistent runtimes are left intact.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote applications(): CuaApplicationState',
|
||||
description: 'Read application decisions without waiting for the native helper.',
|
||||
signature: '@Remote applications(): Promise<CuaApplicationState>',
|
||||
description: 'Read fresh stored application decisions without issuing a native request.',
|
||||
parameters: [],
|
||||
returns: 'Saved grants and refused applications with their persistence owner.',
|
||||
returns: 'Saved grants, refused applications and durable-storage availability.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote async systemStatus(): Promise<CuaSystemPermissionState>',
|
||||
@@ -5594,7 +5594,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CuaApplicationState',
|
||||
declaration: 'export interface CuaApplicationState {\n settingsNamespace: string | null;\n allowedApps: readonly CuaApplicationGrant[];\n blockedApps: readonly CuaApplicationGrant[];\n persistentAvailable: boolean;\n}',
|
||||
declaration: 'export interface CuaApplicationState {\n allowedApps: readonly CuaApplicationGrant[];\n blockedApps: readonly CuaApplicationGrant[];\n persistentAvailable: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CuaPermissionProcess',
|
||||
@@ -6750,7 +6750,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PluginInventoryEntry',
|
||||
declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: PluginLocalizedMeta;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}',
|
||||
declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: PluginLocalizedMeta;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n readonly diagnostic?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PluginLocalizedMeta',
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: dfc89b14d4c0d21e
|
||||
zh: 062b233951ed157b
|
||||
/deepseek-ai-dsh-host-plugin-inventory/use-this-package/what-you-can-and-cannot-do-with-it:
|
||||
en: 387f87777eef7ec5
|
||||
zh: badcd90c8840f870
|
||||
en: 0243819e8404c74a
|
||||
zh: 05dab926c0ecff34
|
||||
/deepseek-ai-dsh-host-plugin-inventory/understand-the-implementation:
|
||||
en: 42b15ac6321706aa
|
||||
zh: 4ed0861911abaf5d
|
||||
|
||||
@@ -41,6 +41,8 @@ With a roster composed, `agentPresets` carries one group per preset in roster or
|
||||
|
||||
The inventory is a snapshot for display and diagnostics: a client can render the roster, flag failed entries, and detect changes by comparing snapshots. It cannot enable, disable, add, or remove plugins, and it carries no history — a fiber that already failed and was removed is absent. Because the service reads the Loader on every call, the answer always reflects the current composition rather than a cached view.
|
||||
|
||||
Failed entries include the current startup diagnostic read from their Cordis fiber. Recovery removes it; inventory reads do not wait for loading entries or retain a separate error cache.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -41,6 +41,8 @@ Loader 行与预设行可以携带可选的 `meta`,其中包含标题、描述
|
||||
|
||||
该清单是供展示与诊断的快照:客户端可以渲染名单、标出失败条目,并通过比较快照检测变化。它不能启用、停用、添加或移除插件,也不携带历史——已经失败并被移除的 fiber 缺席。由于服务每次调用都读取 Loader,答案总是反映当前组合,而不是缓存视图。
|
||||
|
||||
失败条目包含从 Cordis fiber 读取的当前启动诊断。恢复后移除该诊断;清单查询不等待仍在加载的条目,也不另存错误缓存。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
|
||||
@@ -86,11 +86,18 @@ export async function readPluginInventory(ctx: Context): Promise<PluginInventory
|
||||
if (entry.options.group) continue
|
||||
const base = entry.parent.tree.ctx.baseUrl
|
||||
const meta = base === undefined ? undefined : packages?.metaOf(entry.options.name, base)
|
||||
const fiber = entry.fiber
|
||||
const fiberPhase = fiber === undefined ? null : FIBER_PHASE[fiber.state]
|
||||
let diagnostic: string | undefined
|
||||
if (fiber !== undefined && fiberPhase === 'failed') {
|
||||
try { await fiber.await() } catch (error) { diagnostic = error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
entries.push({
|
||||
entryId: pluginEntryId(entry.id),
|
||||
moduleName: entry.options.name,
|
||||
enabled: !entry.disabled,
|
||||
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
|
||||
fiberPhase,
|
||||
...diagnostic === undefined ? {} : { diagnostic },
|
||||
...meta === undefined ? {} : { meta },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface PluginInventoryEntry {
|
||||
/** Effective Loader enablement, including disabled ancestor groups. */
|
||||
readonly enabled: boolean
|
||||
readonly fiberPhase: PluginFiberPhase
|
||||
/** Current startup failure message; absent when this entry has no failed fiber. */
|
||||
readonly diagnostic?: string
|
||||
}
|
||||
|
||||
/** Effective enablement of one preset composition row. */
|
||||
|
||||
@@ -149,6 +149,30 @@ describe('PluginInventoryGateway', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['error', 'stack object'] as const)('reads %s startup diagnostics from failed fibers and clears them after recovery', async (kind) => {
|
||||
const { ctx, inventory } = await harness()
|
||||
const failure: unknown = kind === 'error' ? new Error('native preparation unavailable')
|
||||
: { stack: 'plugin startup failure', toString: () => 'native preparation unavailable' }
|
||||
ctx.loader.builtins.failing = {
|
||||
async apply(_ctx: Context, config: { fail: boolean }) {
|
||||
if (config.fail) throw failure
|
||||
},
|
||||
}
|
||||
const entry = ctx.loader.resolve(await ctx.loader.create({ name: 'cordis:failing', config: { fail: true } }))
|
||||
await expect(entry.fiber!.await()).rejects.toBe(failure)
|
||||
for (let index = 0; index < 2; index++) {
|
||||
expect((await inventory.list()).entries).toContainEqual({
|
||||
entryId: entry.id, moduleName: 'cordis:failing', enabled: true,
|
||||
fiberPhase: 'failed', diagnostic: 'native preparation unavailable',
|
||||
})
|
||||
}
|
||||
await entry.update({ config: { fail: false } })
|
||||
await entry.fiber!.await()
|
||||
expect((await inventory.list()).entries).toContainEqual({
|
||||
entryId: entry.id, moduleName: 'cordis:failing', enabled: true, fiberPhase: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('projects current non-group Loader entries without a second cache', async () => {
|
||||
const { ctx, inventory } = await harness()
|
||||
const activeId = await ctx.loader.create({ name: 'cordis:active' })
|
||||
|
||||
Generated
+17
-2
@@ -9275,6 +9275,9 @@ importers:
|
||||
'@deepseek-ai/dsh-computer-use':
|
||||
specifier: workspace:*
|
||||
version: link:../../computer-use/computer-use
|
||||
'@deepseek-ai/dsh-storage-domain':
|
||||
specifier: workspace:*
|
||||
version: link:../../storage/storage-domain
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../../typert/protocol
|
||||
@@ -9402,9 +9405,12 @@ importers:
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:*
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-settings':
|
||||
'@deepseek-ai/dsh-storage':
|
||||
specifier: workspace:*
|
||||
version: link:../../settings/settings
|
||||
version: link:../../storage/storage
|
||||
'@deepseek-ai/dsh-storage-json':
|
||||
specifier: workspace:*
|
||||
version: link:../../storage/storage-json
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/subprocess
|
||||
@@ -9546,6 +9552,15 @@ importers:
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:*
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/dsh-storage':
|
||||
specifier: workspace:*
|
||||
version: link:../../storage/storage
|
||||
'@deepseek-ai/dsh-storage-domain':
|
||||
specifier: workspace:*
|
||||
version: link:../../storage/storage-domain
|
||||
'@deepseek-ai/dsh-storage-json':
|
||||
specifier: workspace:*
|
||||
version: link:../../storage/storage-json
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:*
|
||||
version: link:../../subprocess/subprocess
|
||||
|
||||
@@ -57,6 +57,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
|
||||
'@deepseek-ai/dsh-deque': ['Deque'],
|
||||
'@deepseek-ai/dsh-llm': ['callConfigEquals'],
|
||||
'@deepseek-ai/dsh-session-format': ['sessionFormatLogFilename'],
|
||||
'@deepseek-ai/dsh-storage-domain': ['defineDomain'],
|
||||
'@deepseek-ai/dsh-timeout': ['MAX_TIMER_DELAY_MS'],
|
||||
'@deepseek-ai/schemastery': ['default'],
|
||||
} as const satisfies HostDependencyExports
|
||||
|
||||
Reference in new issue
Block a user