refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/client/ui-permission-presets/README.i18n.yaml
Normal file
6
packages/client/ui-permission-presets/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
|
||||
README.md: 20529e6ce8a187254774fc85cfd981e4a9115937
|
||||
README.zh.md: afe7ff36e52511a182a783153879125d48cccca5
|
||||
21
packages/client/ui-permission-presets/README.md
Normal file
21
packages/client/ui-permission-presets/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-client-ui-permission-presets
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. Choosing Full access requires an explicit risk acknowledgement before the row writes it.
|
||||
|
||||
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`).
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the permission facts written by its two surfaces: the Settings row causes a future session to start with whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), while the `/permission` picker appends the same facts when it switches the current session; those events select the sandbox mode and approval policy later tool calls resolve, and picker interaction adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the knob consumers own any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution.
|
||||
21
packages/client/ui-permission-presets/README.zh.md
Normal file
21
packages/client/ui-permission-presets/README.zh.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-client-ui-permission-presets
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。
|
||||
|
||||
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过两个界面写入的权限事实间接影响:Settings 行使未来会话带着全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`)启动,而 `/permission` 选择框切换当前会话时会追加相同的事实;这些事件决定后续工具调用解析到的沙箱模式与审批策略,选择框交互本身不添加任何提示词内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接失效;请求前缀的变化由旋钮消费方自行承担。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。
|
||||
91
packages/client/ui-permission-presets/package.json
Normal file
91
packages/client/ui-permission-presets/package.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-permission-presets",
|
||||
"description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-permission-presets"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-commands",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Permission row: title/description plus the preset selector pill. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Permission preference row: the default preset for subsequently created
|
||||
* sessions. Current-session switches remain on the composer `/permission`
|
||||
* control.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
IconChevronDownOutline14, Menu, RiskConfirmation,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PermissionSettingsState } from './settings-store.ts'
|
||||
import type { PermissionSettingsKey } from './locales.ts'
|
||||
import { FULL_ACCESS_PRESET } from './presentation.ts'
|
||||
import css from './PermissionRow.module.css'
|
||||
|
||||
/** Registration-side business face for the host-backed preference. */
|
||||
export interface PermissionRowInjected {
|
||||
hooks: {
|
||||
/** Permission settings snapshot bound by the renderer as usePermission. */
|
||||
permission: SnapshotStore<PermissionSettingsState>
|
||||
}
|
||||
/** Load the descriptor when the row first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Persist one advertised preset. */
|
||||
select: (preset: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type PermissionRowProps =
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'settings.permission'>
|
||||
& InjectFace<PermissionRowInjected>
|
||||
|
||||
/**
|
||||
* Render the new-session Permission default selector.
|
||||
* @param props - composed slot props.
|
||||
* @returns the row, or null when the host does not expose permission settings.
|
||||
*/
|
||||
export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) {
|
||||
const state = usePermission(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmingFullAccess, setConfirmingFullAccess] = useState(false)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.writable && state.status !== 'unavailable') return
|
||||
setOpen(false)
|
||||
setAcknowledged(false)
|
||||
setConfirmingFullAccess(false)
|
||||
}, [state.status, state.writable])
|
||||
|
||||
if (state.status === 'unavailable') return null
|
||||
const selected = state.options.find(option => option.id === state.currentValue)
|
||||
const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
|
||||
const label = selected?.label
|
||||
?? (busy ? t('loading') : t('unavailable'))
|
||||
const description: string = state.error ?? t('description')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({ id: option.id, label: option.label }))}
|
||||
selectedId={state.currentValue}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
if (id === state.currentValue) return
|
||||
if (id === FULL_ACCESS_PRESET) {
|
||||
setAcknowledged(false)
|
||||
setConfirmingFullAccess(true)
|
||||
return
|
||||
}
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<RiskConfirmation
|
||||
open={confirmingFullAccess}
|
||||
title={t('confirm.title')}
|
||||
description={t('confirm.description')}
|
||||
acknowledgeLabel={t('confirm.acknowledge')}
|
||||
cancelLabel={t('confirm.cancel')}
|
||||
confirmLabel={t('confirm.enable')}
|
||||
acknowledged={acknowledged}
|
||||
disabled={!state.writable || state.status === 'saving'}
|
||||
onAcknowledgedChange={setAcknowledged}
|
||||
onCancel={() => {
|
||||
setAcknowledged(false)
|
||||
setConfirmingFullAccess(false)
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setAcknowledged(false)
|
||||
setConfirmingFullAccess(false)
|
||||
void select(FULL_ACCESS_PRESET)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Permission row copy. */
|
||||
'settings.permission': PermissionSettingsKey
|
||||
}
|
||||
}
|
||||
170
packages/client/ui-permission-presets/src/client/index.ts
Normal file
170
packages/client/ui-permission-presets/src/client/index.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Permission preset plugin, browser half — a popupSelect DECORATION hung on
|
||||
* the host `/permission` command: one flat list of presets, current value
|
||||
* marked active, a pick executes the switch. The decoration owns only the
|
||||
* bare invocation; the host command keeps its catalog row, the argued path
|
||||
* (`/permission <preset>` still switches directly), and the lifecycle
|
||||
* logging. Options and the active mark read the session's `permissions`
|
||||
* projection (the same host-computed select the composer chip renders); a
|
||||
* pick submits the `/permission <preset>` command line, so both surfaces
|
||||
* write through one path and the pushed projection frame is the one
|
||||
* confirmation. The Full access row carries the same explicit risk gate as
|
||||
* the composer chip; the shared popup shell owns the modal mechanics.
|
||||
* The General-settings row separately writes the default preset for sessions
|
||||
* created later through the host Settings API.
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the settings slot types (this package registers a General row).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
||||
// (the settings invalidation rides the allowlist) into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandUiContract, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
|
||||
import { PermissionRow } from './PermissionRow.tsx'
|
||||
import type { PermissionRowInjected } from './PermissionRow.tsx'
|
||||
import {
|
||||
accessEn, accessZh, en, zh,
|
||||
} from './locales.ts'
|
||||
import {
|
||||
displayPermissionPreset, FULL_ACCESS_PRESET,
|
||||
} from './presentation.ts'
|
||||
import {
|
||||
PERMISSION_SETTINGS_NS, PermissionPresetSettingsController, refreshPermissionIfLoaded,
|
||||
} from './settings-store.ts'
|
||||
|
||||
export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx'
|
||||
export type {
|
||||
PermissionDefaultOption, PermissionSettingsState,
|
||||
} from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote']
|
||||
|
||||
const ACCESS_NS = 'permission.access'
|
||||
|
||||
/** Read one session's current permissions projection value (undefined = capability absent). */
|
||||
function selectOf(session: SessionFace | undefined): PermissionSelect | undefined {
|
||||
return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined
|
||||
}
|
||||
|
||||
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
|
||||
function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] {
|
||||
return value.options
|
||||
.filter(option => option.value !== 'custom')
|
||||
.map(option => ({
|
||||
id: option.value,
|
||||
label: displayPermissionPreset(option.value, option.name),
|
||||
...(option.description !== undefined ? { detail: option.description } : {}),
|
||||
...(option.value === value.currentValue ? { active: true } : {}),
|
||||
...(option.value === FULL_ACCESS_PRESET
|
||||
? {
|
||||
confirmation: {
|
||||
title: t('confirm.title'),
|
||||
description: t('confirm.description'),
|
||||
acknowledgeLabel: t('confirm.acknowledge'),
|
||||
cancelLabel: t('confirm.cancel'),
|
||||
confirmLabel: t('confirm.enable'),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the /permission popup picker over the
|
||||
* permissions projection.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const command = ctx.get('commandUi') as CommandUiContract
|
||||
const sessions = ctx.sessions
|
||||
// This optional bundle and ui-conversation can load independently, so each
|
||||
// owns the same safety copy under its own locale namespace.
|
||||
/* jscpd:ignore-start */
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(ACCESS_NS, 'zh', {
|
||||
'confirm.title': accessZh['confirm.title'],
|
||||
'confirm.description': accessZh['confirm.description'],
|
||||
'confirm.acknowledge': accessZh['confirm.acknowledge'],
|
||||
'confirm.cancel': accessZh['confirm.cancel'],
|
||||
'confirm.enable': accessZh['confirm.enable'],
|
||||
}),
|
||||
ctx.locale.register(ACCESS_NS, 'en', {
|
||||
'confirm.title': accessEn['confirm.title'],
|
||||
'confirm.description': accessEn['confirm.description'],
|
||||
'confirm.acknowledge': accessEn['confirm.acknowledge'],
|
||||
'confirm.cancel': accessEn['confirm.cancel'],
|
||||
'confirm.enable': accessEn['confirm.enable'],
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-permission: Full access confirmation dictionaries')
|
||||
/* jscpd:ignore-end */
|
||||
const t = ctx.locale.bind(ACCESS_NS)
|
||||
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
|
||||
sessions.binding(session.sessionId)?.session
|
||||
|
||||
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new PermissionPresetSettingsController(connection.api)
|
||||
const load = (): Promise<void> => controller.load()
|
||||
const select = (preset: string): Promise<void> => controller.select(preset)
|
||||
const injected = (): PermissionRowInjected => ({
|
||||
hooks: { permission: controller.store },
|
||||
load,
|
||||
select,
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshPermissionIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
||||
if (ns !== PERMISSION_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => {
|
||||
controller.dispose()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'ui-permission: settings invalidations')
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'permission',
|
||||
order: -20,
|
||||
locale: 'settings.permission',
|
||||
inject: injected,
|
||||
}, PermissionRow))
|
||||
|
||||
ctx.effect(() => command.decorate({
|
||||
name: 'permission',
|
||||
// The picker exists exactly while the projection does: a permission-less
|
||||
// host serves no key and the bare invocation falls through to the host
|
||||
// command (which is absent too — the line simply misses).
|
||||
available: session => selectOf(sessionFor(session)) !== undefined,
|
||||
ui: {
|
||||
kind: 'popupSelect',
|
||||
options: (session) => {
|
||||
const value = selectOf(sessionFor(session))
|
||||
if (value === undefined) throw new Error('permission presets are not available on this host')
|
||||
return Promise.resolve(optionsOf(value, t))
|
||||
},
|
||||
onSelect: async (option, session) => {
|
||||
const live = sessionFor(session)
|
||||
if (live === undefined) throw new Error('this session is not materialized yet')
|
||||
const result = await live.command(`/permission ${option.id}`)
|
||||
if (!result.ok) throw new Error(`permission switch failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (!result.value.matched) throw new Error('the host offers no /permission command')
|
||||
},
|
||||
},
|
||||
}), 'ui-permission: /permission decoration')
|
||||
}
|
||||
51
packages/client/ui-permission-presets/src/client/locales.ts
Normal file
51
packages/client/ui-permission-presets/src/client/locales.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** `settings.permission` namespace dictionaries (the Permission row's copy). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'title': '权限',
|
||||
'description': '选择新会话的默认权限模式',
|
||||
'loading': '加载中',
|
||||
'unavailable': '不可用',
|
||||
'confirm.title': '确认启用 Full access?',
|
||||
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
|
||||
'confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'confirm.cancel': '取消',
|
||||
'confirm.enable': '启用 Full access',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings.permission namespace key union. */
|
||||
export type PermissionSettingsKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'title': 'Permission',
|
||||
'description': 'Choose the default permission mode for new sessions',
|
||||
'loading': 'Loading',
|
||||
'unavailable': 'Unavailable',
|
||||
'confirm.title': 'Enable Full access?',
|
||||
'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
|
||||
'confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'confirm.cancel': 'Cancel',
|
||||
'confirm.enable': 'Enable Full access',
|
||||
} satisfies Record<PermissionSettingsKey, string>
|
||||
|
||||
/** Simplified Chinese dictionary for the current-session popup gate. */
|
||||
export const accessZh = {
|
||||
'confirm.title': '确认启用 Full access?',
|
||||
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'confirm.cancel': '取消',
|
||||
'confirm.enable': '启用 Full access',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Current-session popup-gate key union. */
|
||||
export type PermissionAccessKey = keyof typeof accessZh
|
||||
|
||||
/** English dictionary for the current-session popup gate. */
|
||||
export const accessEn = {
|
||||
'confirm.title': 'Enable Full access?',
|
||||
'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'confirm.cancel': 'Cancel',
|
||||
'confirm.enable': 'Enable Full access',
|
||||
} satisfies Record<PermissionAccessKey, string>
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Machine value of the preset that requires an explicit GUI risk gate. */
|
||||
export const FULL_ACCESS_PRESET = 'danger-full-access'
|
||||
|
||||
/**
|
||||
* Convert conventional kebab-case preset names into user-facing title case.
|
||||
* @param name - host-supplied preset label or key.
|
||||
* @returns the title-cased conventional key, or a non-kebab label unchanged.
|
||||
*/
|
||||
export function displayPresetName(name: string): string {
|
||||
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
|
||||
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a permission preset under its product label.
|
||||
* @param value - preset machine value.
|
||||
* @param name - host-supplied preset name.
|
||||
* @returns the Full access product label or the conventional display name.
|
||||
*/
|
||||
export function displayPermissionPreset(value: string, name: string): string {
|
||||
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Permission default-settings controller. The host descriptor supplies the
|
||||
* current value and the dynamic preset enum; writes target only
|
||||
* `defaultPreset` and carry the descriptor revision.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
createSnapshotStore, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
nodeAtPath, rehydrateSchema, type SchemaNode,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { displayPermissionPreset } from './presentation.ts'
|
||||
|
||||
/** Permission's settings namespace on the host wire. */
|
||||
export const PERMISSION_SETTINGS_NS = 'permission'
|
||||
|
||||
/** One selectable new-session default. */
|
||||
export interface PermissionDefaultOption {
|
||||
/** Preset key written to Settings. */
|
||||
id: string
|
||||
/** Host-supplied label or a title-cased preset key. */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Permission settings-row snapshot. */
|
||||
export interface PermissionSettingsState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
|
||||
error: string | null
|
||||
writable: boolean
|
||||
currentValue: string
|
||||
options: readonly PermissionDefaultOption[]
|
||||
revision: number
|
||||
}
|
||||
|
||||
interface ConstChoice {
|
||||
type: string
|
||||
value?: unknown
|
||||
meta?: { description?: unknown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the dynamic preset enum encoded by the host's `defaultPreset` schema.
|
||||
* @param view - permission namespace descriptor.
|
||||
* @returns current value and selectable options.
|
||||
*/
|
||||
export function permissionDefaultOf(view: SettingsNamespaceView): {
|
||||
currentValue: string
|
||||
options: PermissionDefaultOption[]
|
||||
} {
|
||||
const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset
|
||||
if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value')
|
||||
const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset'])
|
||||
if (node === undefined) throw new Error('permission settings schema has no defaultPreset field')
|
||||
const rawChoices = node.type === 'union'
|
||||
? (node.list as SchemaNode[] | undefined) ?? []
|
||||
: [node]
|
||||
const options = rawChoices.flatMap((candidate) => {
|
||||
const choice = candidate as unknown as ConstChoice
|
||||
if (choice.type !== 'const' || typeof choice.value !== 'string') return []
|
||||
const described = choice.meta?.description
|
||||
return [{
|
||||
id: choice.value,
|
||||
label: typeof described === 'string' && described.length > 0
|
||||
? displayPermissionPreset(choice.value, described)
|
||||
: displayPermissionPreset(choice.value, choice.value),
|
||||
}]
|
||||
})
|
||||
if (options.length === 0 || !options.some(option => option.id === value)) {
|
||||
throw new Error('permission settings schema does not advertise its current preset')
|
||||
}
|
||||
return { currentValue: value, options }
|
||||
}
|
||||
|
||||
/** Controller joining Settings reads, writes, and pushed invalidations. */
|
||||
export class PermissionPresetSettingsController {
|
||||
/** Row snapshot consumed through a bound selector hook. */
|
||||
readonly store: SnapshotStore<PermissionSettingsState> = createSnapshotStore({
|
||||
status: 'idle',
|
||||
error: null,
|
||||
writable: false,
|
||||
currentValue: '',
|
||||
options: [],
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
private view: SettingsNamespaceView | undefined
|
||||
|
||||
/** @param api - Settings wire face. */
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
|
||||
/**
|
||||
* Refresh the permission descriptor. Latest request wins.
|
||||
* @returns nothing; {@link store} carries success or failure.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation !== this.generation) return
|
||||
const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS)
|
||||
if (view === undefined) {
|
||||
this.view = undefined
|
||||
this.store.update((state) => {
|
||||
state.status = 'unavailable'
|
||||
state.writable = false
|
||||
state.currentValue = ''
|
||||
state.options = []
|
||||
})
|
||||
return
|
||||
}
|
||||
this.accept(view, response.result.value.writable)
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for subsequently created sessions.
|
||||
* @param preset - advertised preset key.
|
||||
* @returns nothing; {@link store} carries success or failure.
|
||||
*/
|
||||
async select(preset: string): Promise<void> {
|
||||
const view = this.view
|
||||
const state = this.store.getSnapshot()
|
||||
if (view === undefined || !state.writable) return
|
||||
const generation = ++this.generation
|
||||
this.store.update((draft) => {
|
||||
draft.status = 'saving'
|
||||
draft.error = null
|
||||
})
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: PERMISSION_SETTINGS_NS,
|
||||
ops: [{ op: 'set', path: ['defaultPreset'], value: preset }],
|
||||
expectedRevision: view.revision,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
this.accept(response.result.value, true)
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop in-flight responses from publishing after plugin disposal. */
|
||||
dispose(): void {
|
||||
this.generation += 1
|
||||
this.view = undefined
|
||||
}
|
||||
|
||||
private accept(view: SettingsNamespaceView, writable: boolean): void {
|
||||
const resolved = permissionDefaultOf(view)
|
||||
this.view = view
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.error = null
|
||||
state.writable = writable
|
||||
state.currentValue = resolved.currentValue
|
||||
state.options = resolved.options
|
||||
state.revision = view.revision
|
||||
})
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.error = error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch only after the row has opened once.
|
||||
* @param controller - permission settings controller.
|
||||
*/
|
||||
export function refreshPermissionIfLoaded(controller: PermissionPresetSettingsController): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
4
packages/client/ui-permission-presets/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-permission-presets/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
9
packages/client/ui-permission-presets/src/index.ts
Normal file
9
packages/client/ui-permission-presets/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Permission surfaces plugin, node half. The empty apply exists so the plugin
|
||||
* appears in the host cordis.yml / Loader; the browser half ships the
|
||||
* new-session Settings row and current-session command picker through
|
||||
* exports["./client"], discovered from the package.json dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-permission-presets/src/invariant.ts
Normal file
31
packages/client/ui-permission-presets/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-permission-presets`.
|
||||
* @module @deepseek-ai/dsh-client-ui-permission-presets/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-permission-presets'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-permission-presets-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the command and slot contribution lifecycles are
|
||||
* proven by the HMR-safety spec, while the browser-only Settings controller
|
||||
* owns no host events or cross-plugin mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* ui-permission browser half on a real cordis Context with fake command/
|
||||
* sessions faces: the plugin hangs the /permission popup decoration on the
|
||||
* host command; options flatten the session's permissions projection with
|
||||
* the current value active and `custom` excluded; availability follows the
|
||||
* projection key's presence; a pick submits the /permission line through
|
||||
* Session.command and surfaces rejection/unmatched as thrown errors; fiber
|
||||
* disposal removes the contribution (HMR safety). The same plugin registers
|
||||
* its Settings row and invalidates that row on host settings changes.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
|
||||
import {
|
||||
PermissionRow, type PermissionRowInjected,
|
||||
} from '../src/client/PermissionRow.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { accessEn } from '../src/client/locales.ts'
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
|
||||
const SELECT: PermissionSelect = {
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only', description: 'Reads only.' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
currentValue: 'workspace-write',
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
locale.setLocale('en')
|
||||
ctx.provide('locale', locale)
|
||||
// The plugin injects `remote`; forwarded events reach it through the same
|
||||
// `$dispatch` handoff the connection sink makes.
|
||||
new TestRemote(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'settings.general.item': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
settings: {
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'describe',
|
||||
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
|
||||
}),
|
||||
mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
let decoration: CommandDecoration | undefined
|
||||
ctx.provide('commandUi', {
|
||||
decorate(c: CommandDecoration) {
|
||||
decoration = c
|
||||
return () => { decoration = undefined }
|
||||
},
|
||||
})
|
||||
const values = new Map<SessionId, PermissionSelect>()
|
||||
const commands: string[] = []
|
||||
let commandResult: { ok: boolean; matched?: boolean } = { ok: true, matched: true }
|
||||
const session = (id: SessionId) => ({
|
||||
projections: {
|
||||
faceOf: (key: string) => ({
|
||||
getSnapshot: () => (key === 'permissions' ? values.get(id) : undefined),
|
||||
subscribe: () => () => {},
|
||||
}),
|
||||
},
|
||||
command: (line: string) => {
|
||||
commands.push(line)
|
||||
return Promise.resolve(commandResult.ok
|
||||
? { ok: true as const, value: { matched: commandResult.matched ?? true } }
|
||||
: { ok: false as const, error: { code: 'internal', message: 'boom' } })
|
||||
},
|
||||
})
|
||||
ctx.provide('sessions', {
|
||||
binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined),
|
||||
})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return {
|
||||
ctx, fiber, values, commands,
|
||||
setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
|
||||
decoration: () => decoration,
|
||||
permissionRow: () => ctx.slots.entries('settings.general.item')
|
||||
.find(entry => entry.component === PermissionRow),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-permission browser plugin', () => {
|
||||
it('hangs the /permission popup decoration on the host command', async () => {
|
||||
const b = await bench()
|
||||
const c = b.decoration()!
|
||||
expect(c.name).toBe('permission')
|
||||
expect(c.ui.kind).toBe('popupSelect')
|
||||
const row = b.permissionRow()!
|
||||
expect(row.options).toEqual({ id: 'permission', order: -20 })
|
||||
const injected = row.inject?.() as PermissionRowInjected | undefined
|
||||
expect(injected?.hooks.permission).toBeDefined()
|
||||
expect(typeof injected?.load).toBe('function')
|
||||
expect(typeof injected?.select).toBe('function')
|
||||
await injected!.load()
|
||||
await injected!.select('read-only')
|
||||
})
|
||||
|
||||
it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
|
||||
const b = await bench()
|
||||
const c = b.decoration()!
|
||||
const proj = { sessionId: sid('s1') }
|
||||
expect(c.available(proj)).toBe(false)
|
||||
b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
|
||||
expect(c.available(proj)).toBe(true)
|
||||
const options = await c.ui.options(proj, new AbortController().signal)
|
||||
expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
|
||||
expect(options.every(option => option.active !== true)).toBe(true)
|
||||
b.values.set(sid('s1'), SELECT)
|
||||
const again = await c.ui.options(proj, new AbortController().signal)
|
||||
expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
|
||||
expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
|
||||
// Kebab-case names title-case; non-kebab host-configured names pass through.
|
||||
expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
|
||||
expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
|
||||
title: 'Enable Full access?',
|
||||
description: accessEn['confirm.description'],
|
||||
acknowledgeLabel: 'I understand the risks and want to continue',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Enable Full access',
|
||||
})
|
||||
b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
|
||||
const passthrough = await c.ui.options(proj, new AbortController().signal)
|
||||
expect(passthrough[0]?.label).toBe('Ask Every Time')
|
||||
// A projection that vanished between availability and open throws.
|
||||
expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
|
||||
.toThrow(/not available on this host/)
|
||||
})
|
||||
|
||||
it('a pick submits the /permission line; rejection and unmatched throw', async () => {
|
||||
const b = await bench()
|
||||
const c = b.decoration()!
|
||||
const proj = { sessionId: sid('s1') }
|
||||
b.values.set(sid('s1'), SELECT)
|
||||
await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
|
||||
expect(b.commands).toEqual(['/permission danger-full-access'])
|
||||
b.setResult({ ok: false })
|
||||
await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
|
||||
b.setResult({ ok: true, matched: false })
|
||||
await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
|
||||
// An unmaterialized session throws before any submit.
|
||||
await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
|
||||
.rejects.toThrow(/not materialized/)
|
||||
})
|
||||
|
||||
it('disposal removes the decoration (HMR safety)', async () => {
|
||||
const b = await bench()
|
||||
expect(b.decoration()).toBeDefined()
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['another', 1])
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1])
|
||||
b.ctx.emit('connection/reset')
|
||||
await b.fiber.dispose()
|
||||
expect(b.decoration()).toBeUndefined()
|
||||
expect(b.permissionRow()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SCHEMA = {
|
||||
uid: 5,
|
||||
refs: {
|
||||
1: { type: 'const', value: 'read-only' },
|
||||
2: { type: 'const', value: 'workspace-write' },
|
||||
3: { type: 'const', value: 'danger-full-access' },
|
||||
4: { type: 'union', list: [1, 2, 3] },
|
||||
5: { type: 'object', dict: { defaultPreset: 4 } },
|
||||
},
|
||||
}
|
||||
|
||||
function view(defaultPreset: string, revision = 0): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'permission',
|
||||
schema: SCHEMA,
|
||||
value: { defaultPreset },
|
||||
base: { defaultPreset: 'read-only' },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function ok<T>(value: T) {
|
||||
return { rpcId: 'test', result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
const dictionary: Record<string, string> = en
|
||||
const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
|
||||
const runtime = {
|
||||
useSessions: (() => { throw new Error('unused') }) as never,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as never,
|
||||
}
|
||||
|
||||
function mount(controller: PermissionPresetSettingsController) {
|
||||
return render(
|
||||
<PermissionRow
|
||||
{...runtime}
|
||||
load={() => controller.load()}
|
||||
select={preset => controller.select(preset)}
|
||||
usePermission={bindSnapshotSelector(controller.store)}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('PermissionRow', () => {
|
||||
it('loads the descriptor, opens the menu, and selects a new default', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
|
||||
const controller = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
mount(controller)
|
||||
const button = await screen.findByRole('button', { name: 'Read Only' })
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') })
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' }))
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
|
||||
await screen.findByRole('button', { name: 'Workspace Write' })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires explicit acknowledgement before saving Full access', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
|
||||
const controller = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
mount(controller)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Read Only' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
|
||||
const enable = screen.getByRole('button', { name: 'Enable Full access' })
|
||||
expect((enable as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(enable)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(dialog.isConnected).toBe(false)
|
||||
})
|
||||
|
||||
it('hides an unavailable namespace and disables a read-only provider', async () => {
|
||||
const absent = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
|
||||
mutate: vi.fn(),
|
||||
} as never,
|
||||
})
|
||||
const rendered = mount(absent)
|
||||
await waitFor(() => { expect(rendered.container.textContent).toBe('') })
|
||||
rendered.unmount()
|
||||
|
||||
const readonly = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: vi.fn(),
|
||||
} as never,
|
||||
})
|
||||
mount(readonly)
|
||||
expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows loading and a contained write error', async () => {
|
||||
const describe = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const controller = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => describe.promise,
|
||||
mutate: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} },
|
||||
},
|
||||
}),
|
||||
} as never,
|
||||
})
|
||||
mount(controller)
|
||||
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
|
||||
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
|
||||
const button = await screen.findByRole('button', { name: 'Read Only' })
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded,
|
||||
} from '../src/client/settings-store.ts'
|
||||
|
||||
const SCHEMA = {
|
||||
uid: 6,
|
||||
refs: {
|
||||
1: { type: 'const', value: 'read-only' },
|
||||
2: { type: 'const', meta: { description: 'Workspace' }, value: 'workspace-write' },
|
||||
3: { type: 'union', list: [1, 2] },
|
||||
6: { type: 'object', dict: { defaultPreset: 3 } },
|
||||
},
|
||||
}
|
||||
|
||||
function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'permission',
|
||||
schema,
|
||||
value: { defaultPreset },
|
||||
base: { defaultPreset: 'read-only' },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
function ok<T>(value: T) {
|
||||
return { rpcId: 'test', result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
describe('permission settings store', () => {
|
||||
it('derives dynamic options and host labels from the descriptor schema', () => {
|
||||
expect(permissionDefaultOf(view('read-only'))).toEqual({
|
||||
currentValue: 'read-only',
|
||||
options: [
|
||||
{ id: 'read-only', label: 'Read Only' },
|
||||
{ id: 'workspace-write', label: 'Workspace' },
|
||||
],
|
||||
})
|
||||
const single = {
|
||||
uid: 2,
|
||||
refs: {
|
||||
1: { type: 'const', meta: { description: '' }, value: 'read-only' },
|
||||
2: { type: 'object', dict: { defaultPreset: 1 } },
|
||||
},
|
||||
}
|
||||
expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({
|
||||
currentValue: 'read-only',
|
||||
options: [{ id: 'read-only', label: 'Read Only' }],
|
||||
})
|
||||
const undescribed = {
|
||||
uid: 2,
|
||||
refs: {
|
||||
1: { type: 'const', meta: { description: 7 }, value: 'read-only' },
|
||||
2: { type: 'object', dict: { defaultPreset: 1 } },
|
||||
},
|
||||
}
|
||||
expect(permissionDefaultOf(view('read-only', 0, undescribed)).options)
|
||||
.toEqual([{ id: 'read-only', label: 'Read Only' }])
|
||||
})
|
||||
|
||||
it('rejects malformed values and dynamic enums at the wire boundary', () => {
|
||||
expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/)
|
||||
expect(() => permissionDefaultOf(view('read-only', 0, {
|
||||
uid: 1, refs: { 1: { type: 'object', dict: {} } },
|
||||
}))).toThrow(/no defaultPreset field/)
|
||||
expect(() => permissionDefaultOf(view('read-only', 0, {
|
||||
uid: 2,
|
||||
refs: {
|
||||
1: { type: 'union' },
|
||||
2: { type: 'object', dict: { defaultPreset: 1 } },
|
||||
},
|
||||
}))).toThrow(/does not advertise/)
|
||||
expect(() => permissionDefaultOf(view('read-only', 0, {
|
||||
uid: 4,
|
||||
refs: {
|
||||
1: { type: 'string' },
|
||||
2: { type: 'const', value: 1 },
|
||||
3: { type: 'union', list: [1, 2] },
|
||||
4: { type: 'object', dict: { defaultPreset: 3 } },
|
||||
},
|
||||
}))).toThrow(/does not advertise/)
|
||||
expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/)
|
||||
})
|
||||
|
||||
it('loads and writes defaultPreset with optimistic concurrency', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [view('read-only', 4)],
|
||||
})))
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
|
||||
const controller = new PermissionPresetSettingsController({
|
||||
settings: { describe, mutate } as never,
|
||||
})
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
writable: true,
|
||||
currentValue: 'read-only',
|
||||
revision: 4,
|
||||
})
|
||||
await controller.select('workspace-write')
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: 'permission',
|
||||
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
|
||||
expectedRevision: 4,
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready',
|
||||
currentValue: 'workspace-write',
|
||||
revision: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('hides the row when the namespace is absent and contains write failures', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
|
||||
const controller = new PermissionPresetSettingsController({
|
||||
settings: { describe, mutate: vi.fn() } as never,
|
||||
})
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
|
||||
const failing = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'settings-conflict', message: 'stale', details: {} },
|
||||
},
|
||||
}),
|
||||
} as never,
|
||||
})
|
||||
await failing.load()
|
||||
await failing.select('workspace-write')
|
||||
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' })
|
||||
})
|
||||
|
||||
it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => {
|
||||
const first = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
|
||||
const mutate = vi.fn()
|
||||
const controller = new PermissionPresetSettingsController({
|
||||
settings: { describe, mutate } as never,
|
||||
})
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
currentValue: 'read-only',
|
||||
writable: false,
|
||||
revision: 2,
|
||||
})
|
||||
await controller.select('workspace-write')
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
const rejected = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve({
|
||||
rpcId: 'test',
|
||||
result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } },
|
||||
}),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
await rejected.select('workspace-write')
|
||||
await rejected.load()
|
||||
expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' })
|
||||
|
||||
const thrown = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
// Promise consumers must contain unknown rejection values from a
|
||||
// transport implementation, including non-Error legacy clients.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
describe: () => Promise.reject('disconnected'),
|
||||
mutate,
|
||||
} as never,
|
||||
})
|
||||
await thrown.load()
|
||||
expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' })
|
||||
})
|
||||
|
||||
it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => {
|
||||
const read = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const describe = vi.fn(() => read.promise)
|
||||
const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never })
|
||||
refreshPermissionIfLoaded(idle)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const loading = idle.load()
|
||||
idle.dispose()
|
||||
read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
|
||||
await loading
|
||||
expect(idle.store.getSnapshot().status).toBe('loading')
|
||||
|
||||
const rejectedRead = Promise.withResolvers<ReturnType<typeof ok<{
|
||||
writable: boolean
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}>>>()
|
||||
const disposedRead = new PermissionPresetSettingsController({
|
||||
settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never,
|
||||
})
|
||||
const reading = disposedRead.load()
|
||||
disposedRead.dispose()
|
||||
rejectedRead.reject(new Error('late read'))
|
||||
await reading
|
||||
expect(disposedRead.store.getSnapshot().status).toBe('loading')
|
||||
|
||||
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const activeDescribe = vi.fn(() => Promise.resolve(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: [view('read-only')],
|
||||
})))
|
||||
const active = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: activeDescribe,
|
||||
mutate: () => mutation.promise,
|
||||
} as never,
|
||||
})
|
||||
await active.load()
|
||||
refreshPermissionIfLoaded(active)
|
||||
await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) })
|
||||
const saving = active.select('workspace-write')
|
||||
active.dispose()
|
||||
mutation.resolve(ok(view('workspace-write', 1)))
|
||||
await saving
|
||||
expect(active.store.getSnapshot().status).toBe('saving')
|
||||
|
||||
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
|
||||
const disposedWrite = new PermissionPresetSettingsController({
|
||||
settings: {
|
||||
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
|
||||
mutate: () => rejectedMutation.promise,
|
||||
} as never,
|
||||
})
|
||||
await disposedWrite.load()
|
||||
const writing = disposedWrite.select('workspace-write')
|
||||
disposedWrite.dispose()
|
||||
rejectedMutation.reject(new Error('late write'))
|
||||
await writing
|
||||
expect(disposedWrite.store.getSnapshot().status).toBe('saving')
|
||||
})
|
||||
})
|
||||
51
packages/client/ui-permission-presets/tsconfig.json
Normal file
51
packages/client/ui-permission-presets/tsconfig.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../ui-commands"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-input-trigger"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/permission-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-permission-presets/tsdown.config.ts
Normal file
3
packages/client/ui-permission-presets/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-permission-presets', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user