refactor(agent-presets,web): copy-only preset authoring with a path to the files

The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.

The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.

With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.

New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
Yichen Jiang
2026-08-08 22:35:26 +08:00
parent 2cea99409f
commit b77fb9036c
60 changed files with 2253 additions and 1336 deletions

View File

@@ -2,5 +2,5 @@
# 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/host/apiproxy/README.md
README.md: 9c5c85ec1ab413c7f12f8f8f222935229d259973
README.zh.md: ad1756bba5b5f618059a878d175a3213cbfaa3f9
README.md: 125ba12a64ae9c26e585b50dbe0cc5651febd474
README.zh.md: e9fe87ecad30a446745668bc6f2a35c7dfc96bc5

View File

@@ -48,7 +48,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names) and whether it is the current default. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
`agentPreset.read`, `write`, and `remove` author the compositions themselves. `read` reports the text with its `trust` and whether it is `writable`; `write` and `remove` refuse a preset that ships with the deployment, and `write` refuses an id that is not a containable directory name or text that is not a Cordis entry list (`agent-preset-invalid`), a refusal that reaches the caller as `agent-preset-read-only` for the shipped case. These three are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports `authorable`, whether the deployment configures a root a new preset could be written to at all.
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -48,7 +48,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust``user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent且仅在会话空白时允许一旦跑过任何轮次那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
`agentPreset.read``write``remove` 负责创作组装本身。`read` 返回文本连同它的 `trust` 以及是否 `writable``write` `remove` 拒绝随部署提供的 preset`write` 还拒绝不构成可约束目录名的 id 或不是 Cordis entry 列表的文本(`agent-preset-invalid`),而随部署提供这一情形以 `agent-preset-read-only` 抵达调用方。这个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力`list``select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告 `authorable`,即部署是否配置了可供写入新 preset 的根目录。
`agentPreset.read``copy``openDocument``remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid``remove` 对随附 preset 回答 `agent-preset-read-only``openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径因此没有任何浏览器载荷能选中任意文件系统目标部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面`list``select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -5,7 +5,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -25,7 +25,7 @@ import {
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
InvalidCompositionError, InvalidPresetIdError, PresetMountError,
InvalidPresetIdError, PresetExistsError, PresetMountError,
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
@@ -83,7 +83,7 @@ import {
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from '@deepseek-ai/dsh-api-remotes'
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -415,6 +415,14 @@ export interface ApiProxyDefaults {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
/**
* Whether handing a path to the native opener can work at all — the
* `hasDocument` capability the preset roster reports, and the switch
* between opening a preset directory and answering its path as text.
* Absent, an injected `openPath` counts as openable and everything else
* falls back to platform detection ({@link canOpenNativePath}).
*/
canOpenPath?: () => boolean
}
/** The tool/call payload fields the presenter path reads. */
@@ -757,7 +765,7 @@ function presetError(agentPreset: string, error: unknown): RpcError {
if (error instanceof PresetNotWritableError) {
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
}
if (error instanceof InvalidPresetIdError || error instanceof InvalidCompositionError) {
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
}
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
@@ -1543,6 +1551,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return openTarget(request, path, signal, open)
}
/** Whether this deployment can hand a path to a native opener at all. */
function canOpenPaths(): boolean {
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
// An injected opener is by definition usable; otherwise ask the platform.
return defaults.openPath !== undefined || canOpenNativePath()
}
/** Missing-service report shared by the credentials domain. */
function credentialsAbsent(): RpcError {
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
@@ -2614,7 +2629,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// simply offers no choice.
async list(request) {
const presets = ctx.get('agentPresets')
if (presets === undefined) return ok(request, { presets: [], authorable: false })
if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
const defaultId = presets.defaultId
return ok(request, {
presets: (await presets.list()).map(preset => ({
@@ -2625,6 +2640,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
...preset.description === undefined ? {} : { description: preset.description },
})),
authorable: presets.authorable,
hasDocument: canOpenPaths(),
})
},
@@ -2682,7 +2698,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
// a composition names the plugins a session runs, so reading one is
// reconnaissance and writing one is arbitrary capability.
// reconnaissance, and copy/remove/openDocument manage the roster and
// drive the host desktop.
async read(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
@@ -2693,7 +2710,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
agentPreset: preset.id,
trust: preset.trust,
content: await presets.read(preset.id),
writable: preset.trust === 'user' && presets.authorable,
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
})
@@ -2702,21 +2718,41 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async write(request) {
const { agentPreset, content, name, description } = request.payload
async copy(request) {
const { from, agentPreset, name } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
await presets.write(agentPreset, content, {
...name === undefined ? {} : { name },
...description === undefined ? {} : { description },
})
await presets.copy(from, agentPreset, name)
return ok(request, { agentPreset })
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async openDocument(request, signal) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
const preset = await presets.resolve(agentPreset)
// Same line as copy/remove draw: the shipped install is not the
// user's to manage, and pointing an editor into it invites edits an
// upgrade will silently overwrite.
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
// The id resolved against the Host's own roots is what selects the
// directory — no browser payload carries a path in either direction
// unless the deployment has no opener to hand it to.
const directory = dirname(preset.path)
if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
return await openPath(request, directory, signal)
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async remove(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')

View File

@@ -26,6 +26,7 @@ export const agentPresetListRequestSchema = z.object({
export const agentPresetListValueSchema = z.object({
presets: z.array(agentPresetEntrySchema),
authorable: z.boolean(),
hasDocument: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
/** agentPreset.select request payload. */
@@ -49,23 +50,32 @@ export const agentPresetReadValueSchema = z.object({
agentPreset: z.string(),
trust: z.union([z.literal('system'), z.literal('user')]),
content: z.string(),
writable: z.boolean(),
name: z.string().optional(),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
/** agentPreset.write request payload. */
export const agentPresetWriteRequestSchema = z.object({
/** agentPreset.copy request payload. */
export const agentPresetCopyRequestSchema = z.object({
from: z.string().min(1),
agentPreset: z.string().min(1),
content: z.string(),
name: z.string().optional(),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.write'>>>
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>>
/** agentPreset.write response value. */
export const agentPresetWriteValueSchema = z.object({
/** agentPreset.copy response value. */
export const agentPresetCopyValueSchema = z.object({
agentPreset: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.write'>>>
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
/** agentPreset.openDocument request payload. */
export const agentPresetOpenDocumentRequestSchema = z.object({
agentPreset: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>>
/** agentPreset.openDocument response value. */
export const agentPresetOpenDocumentValueSchema = z.union([
z.object({ opened: z.literal(true) }),
z.object({ opened: z.literal(false), path: z.string() }),
]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
/** agentPreset.remove request payload. */
export const agentPresetRemoveRequestSchema = z.object({

View File

@@ -3,10 +3,10 @@
* session, plus the authoring calls behind it.
*
* `list` is ordinary: it carries ids and trust, and every preset picker needs
* it. Everything else is privileged and loopback-pinned — a composition names
* the plugins a session runs, so reading one is reconnaissance, writing one is
* arbitrary capability, and selecting one can move a session onto a preset
* that edits the live runtime.
* it. The authoring calls are privileged and loopback-pinned — a composition
* names the plugins a session runs, so reading one is reconnaissance, and
* although authoring is copy-only (no caller supplies composition text or a
* path), copying and deleting still rearrange what the deployment offers.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -45,11 +45,13 @@ export interface AgentPresetsApi {
* shipped ids.
* An empty roster means the deployment composes no presets at all, and
* every session shares the host composition. `authorable` reports whether
* the deployment configures a root new presets can be written to, which is
* a deployment fact rather than a per-preset one.
* the deployment configures a root new presets can be written to, and
* `hasDocument` whether `openDocument` can hand a preset directory to a
* native opener — both deployment facts rather than per-preset ones, and
* neither exposes a Host path.
*/
list(request: RpcRequest<{}>):
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean }>>
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
/**
* Recompose one session's agent from a different preset.
@@ -63,29 +65,45 @@ export interface AgentPresetsApi {
Promise<RpcResponse<{ agentPreset: string }>>
/**
* Read one preset's composition text, for an editor.
* Read one preset's composition text, for the read-only viewer.
*
* Privileged: a composition names the plugins a session runs, so reading one
* is reconnaissance and writing one is arbitrary capability.
* Privileged: a composition names the plugins a session runs, so reading
* one is reconnaissance.
*/
read(request: RpcRequest<{ agentPreset: string }>):
Promise<RpcResponse<{
agentPreset: string
trust: 'system' | 'user'
content: string
writable: boolean
name?: string
description?: string
}>>
/**
* Create or replace a locally authored preset. Shipped presets are refused;
* the text is shape-checked before it lands, so a save cannot leave a file no
* session could load.
* Create a locally authored preset by copying an existing one whole.
*
* The only authoring write. No composition text and no path crosses the
* wire: `from` and `agentPreset` are ids the Host resolves against its own
* roots, so a copy is exactly as loadable as its source and grants nothing
* the roster did not already carry. The copy keeps the source's description
* (the file is the author's to edit afterwards) but not its name — `name`
* here or the id fallback is what distinguishes the rows.
*/
write(request: RpcRequest<{ agentPreset: string; content: string; name?: string; description?: string }>):
copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
Promise<RpcResponse<{ agentPreset: string }>>
/**
* Hand one locally authored preset's DIRECTORY to the platform opener, for
* editing the files that are now the only composition editor. The request
* carries an id, never a path — the Host resolves it — so no browser
* payload can select an arbitrary filesystem target. Where the deployment
* has no native opener (`hasDocument: false` on `list`), the reply carries
* the resolved directory for the surface to show as text instead. Shipped
* presets are refused: their install is not the user's to manage.
*/
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
/** Delete a locally authored preset. Shipped presets are refused. */
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
}

View File

@@ -54,7 +54,8 @@ export interface RpcMethodMap {
'agentPreset.list': AgentPresetsApi['list']
'agentPreset.select': AgentPresetsApi['select']
'agentPreset.read': AgentPresetsApi['read']
'agentPreset.write': AgentPresetsApi['write']
'agentPreset.copy': AgentPresetsApi['copy']
'agentPreset.openDocument': AgentPresetsApi['openDocument']
'agentPreset.remove': AgentPresetsApi['remove']
'goal.create': GoalsApi['create']
'goal.edit': GoalsApi['edit']

View File

@@ -41,8 +41,8 @@ import {
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import {
agentPresetListValueSchema, agentPresetReadValueSchema, agentPresetRemoveValueSchema,
agentPresetSelectValueSchema, agentPresetWriteValueSchema,
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
} from '../api/agent-presets.schema.ts'
import {
goalCreateValueSchema,
@@ -127,7 +127,8 @@ export interface IApiClient {
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
write(payload: RequestPayload<'agentPreset.write'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.write'>>>
copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
}
events: {
@@ -199,7 +200,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'agentPreset.list': agentPresetListValueSchema,
'agentPreset.select': agentPresetSelectValueSchema,
'agentPreset.read': agentPresetReadValueSchema,
'agentPreset.write': agentPresetWriteValueSchema,
'agentPreset.copy': agentPresetCopyValueSchema,
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
'agentPreset.remove': agentPresetRemoveValueSchema,
'goal.create': goalCreateValueSchema,
'goal.edit': goalEditValueSchema,
@@ -468,7 +470,8 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal),
select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal),
read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal),
write: (payload, signal) => this.callUnary('agentPreset.write', payload, signal),
copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal),
openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal),
}

View File

@@ -43,8 +43,8 @@ import {
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import {
agentPresetListRequestSchema, agentPresetReadRequestSchema, agentPresetRemoveRequestSchema,
agentPresetSelectRequestSchema, agentPresetWriteRequestSchema,
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
} from '../api/agent-presets.schema.ts'
import {
goalCreateRequestSchema,
@@ -116,7 +116,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
'agentPreset.write': { schema: agentPresetWriteRequestSchema, invoke: (api, r) => api.agentPresets.write(r) },
'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) },
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) },
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },

View File

@@ -71,6 +71,14 @@ export interface Config {
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
/**
* Whether this deployment can hand paths to a native desktop opener —
* the `hasDocument` capability the agent-preset roster reports. Absent,
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
* server); set it explicitly where detection misleads, e.g. `false` in a
* container whose DISPLAY points nowhere a user can see.
*/
nativeOpen?: boolean
}
/**
@@ -109,6 +117,7 @@ export class ApiProxyService extends Service implements ApiProxy {
provider: z.string().required(),
model: z.string().required(),
workspaceRoot: z.string(),
nativeOpen: z.boolean(),
})
readonly sessions: ApiProxy['sessions']
@@ -154,6 +163,7 @@ export class ApiProxyService extends Service implements ApiProxy {
},
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
})
this.sessions = api.sessions
this.subagents = api.subagents

View File

@@ -152,6 +152,25 @@ async function openNativePathWithIntent(
throw new Error(`native path opener is unsupported on ${platform}`)
}
/**
* Whether {@link openNativePath} plausibly reaches a desktop on this host.
*
* macOS and Windows always carry a desktop opener; Linux does when it is WSL
* (the Windows desktop takes the path) or a display server is announced.
* A headless or containerised Linux host answers false, which is what lets a
* surface show a path as text instead of offering a button that would spawn
* `xdg-open` into nothing.
* @param internals - platform and environment seam for deterministic tests.
* @returns true when handing a path to the native opener can work at all.
*/
export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
const platform = internals.platform ?? process.platform
if (platform === 'darwin' || platform === 'win32') return true
if (platform !== 'linux') return false
const env = internals.env ?? process.env
return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
}
/**
* Open a filesystem path with the operating system's default application, or
* with the default browser when the path names a document a browser renders.

View File

@@ -15,7 +15,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
import {
InvalidCompositionError, InvalidPresetIdError, resolveSessionPreset, UnknownPresetError,
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import { GoalId } from '@deepseek-ai/dsh-goal'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -34,19 +34,22 @@ function stubAgent(session: Session): Agent {
/**
* A roster whose `mount` is a no-op: this spec is about the gateway's identity
* rules, and the composition itself is covered by the real-composition test in
* `apps/cli`.
* `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
* ship with the deployment.
*/
function roster(ids: readonly string[]): unknown {
function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
const presetOf = (id: string): object =>
({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
return {
defaultId: ids[0],
list: () => Promise.resolve(ids.map(id => ({ id, trust: 'system', path: `/presets/${id}.yml` }))),
list: () => Promise.resolve(ids.map(presetOf)),
resolve: (id?: string) => {
const wanted = id ?? ids[0] ?? ''
if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
return Promise.resolve({ id: wanted, trust: 'system', path: `/presets/${wanted}.yml` })
return Promise.resolve(presetOf(wanted))
},
mount: (_ctx: Context, id?: string) =>
Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
// What a real mount leaves behind: a service instance only the agent that
// mounted it can be used to address. The doubles are per agent so a test
// can tell "this session's" from "some session's".
@@ -56,11 +59,10 @@ function roster(ids: readonly string[]): unknown {
},
authorable: true,
read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
write: (id: string, content: string) => {
if (!ids.includes(id) && !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
return Promise.reject(new InvalidPresetIdError(id))
}
if (!content.trimStart().startsWith('-')) return Promise.reject(new InvalidCompositionError('not a list'))
copy: (from: string, id: string) => {
if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
return Promise.resolve()
},
remove: (id: string) => {
@@ -97,14 +99,18 @@ const failingStandingKeys = new Set<string>()
/** Per-agent service instances a mounted preset would own, keyed by session id. */
const services = new Map<string, Record<string, unknown>>()
async function harness(presets?: readonly string[], persistence?: unknown) {
async function harness(
presets?: readonly string[],
persistence?: unknown,
options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
) {
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
@@ -131,6 +137,7 @@ async function harness(presets?: readonly string[], persistence?: unknown) {
defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
cwd,
workspaceRoot: cwd,
...options.defaults,
})
return { api, ctx, cwd }
}
@@ -404,38 +411,59 @@ describe('agentPreset.select', () => {
})
describe('authoring over the wire', () => {
it('reads a composition and reports whether it may be edited', async () => {
it('reads a composition with its trust', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
// The shipped set is readable but not writable: it belongs to the
// deployment, and it is what a broken local preset is compared against.
// The shipped set is readable: it is the known-good composition a copy
// starts from, and trust is what tells a surface to say so.
expect(response.result.value.trust).toBe('system')
expect(response.result.value.writable).toBe(false)
expect(response.result.value.content).toContain('- id: x')
})
it('rejects an id that could escape the preset root', async () => {
it('copies a preset under a new id', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.write(request({ agentPreset: '../escape', content: '- id: x\n' }))
const response = await api.agentPresets.copy(
request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.agentPreset).toBe('mine')
})
it('rejects a copy target that could escape the preset root', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-invalid')
})
it('rejects content that is not an entry list', async () => {
const { api } = await harness(['standard'])
it('rejects a copy target the roster already supplies', async () => {
const { api } = await harness(['standard', 'minimal'])
const response = await api.agentPresets.write(request({ agentPreset: 'mine', content: 'tools: []\n' }))
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-invalid')
expect(response.result.error.message).toMatch(/already exists/)
})
it('rejects a copy whose source is unknown', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-not-found')
})
it('reports a deployment that composes no presets', async () => {
@@ -459,6 +487,81 @@ describe('authoring over the wire', () => {
})
})
describe('opening a preset directory', () => {
it('hands the resolved directory to the native opener', async () => {
const opened: string[] = []
const { api } = await harness(['standard', 'my-preset'], undefined, {
userIds: ['my-preset'],
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
})
const response = await api.agentPresets.openDocument(
request({ agentPreset: 'my-preset' }), new AbortController().signal)
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value).toEqual({ opened: true })
// The id selected the directory; the browser supplied no path.
expect(opened).toEqual(['/presets/my-preset'])
})
it('answers the path as text where the deployment has no opener', async () => {
const { api } = await harness(['standard', 'my-preset'], undefined, {
userIds: ['my-preset'],
defaults: { canOpenPath: () => false },
})
const response = await api.agentPresets.openDocument(
request({ agentPreset: 'my-preset' }), new AbortController().signal)
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
})
it('refuses a preset that ships with the deployment', async () => {
const opened: string[] = []
const { api } = await harness(['standard'], undefined, {
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
})
const response = await api.agentPresets.openDocument(
request({ agentPreset: 'standard' }), new AbortController().signal)
// Pointing an editor into the install invites edits an upgrade will
// silently overwrite; the refusal mirrors copy/remove.
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-read-only')
expect(opened).toEqual([])
})
it('reports the roster capability on list', async () => {
const openable = await harness(['standard'], undefined, {
defaults: { canOpenPath: () => true },
})
const headless = await harness(['standard'], undefined, {
defaults: { canOpenPath: () => false },
})
const yes = await openable.api.agentPresets.list(request({}))
const no = await headless.api.agentPresets.list(request({}))
expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
expect(no.result.ok && no.result.value.hasDocument).toBe(false)
})
it('counts an injected opener as openable', async () => {
const { api } = await harness(['standard'], undefined, {
defaults: { openPath: () => Promise.resolve() },
})
const response = await api.agentPresets.list(request({}))
expect(response.result.ok && response.result.value.hasDocument).toBe(true)
})
})
describe('session.history presenter scope', () => {
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
const { api } = await harness(['standard', 'core-web'])

View File

@@ -89,10 +89,11 @@ function scriptedApi(overrides: {
},
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
agentPresets: {
list: r => ok(r, { presets: [], authorable: false }),
list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),
select: r => ok(r, { agentPreset: r.payload.agentPreset }),
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '', writable: true }),
write: r => ok(r, { agentPreset: r.payload.agentPreset }),
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
openDocument: r => ok(r, { opened: true as const }),
remove: r => ok(r, {}),
...overrides.agentPresets,
},
@@ -234,7 +235,7 @@ describe('unary round trip', () => {
const c = client(scriptedApi())
const listed = await c.agentPresets.list({})
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false } })
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
// The switch carries the session it is about: the host refuses one whose
// conversation has started, and it can only know which by id.

View File

@@ -198,7 +198,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
list(request: RpcRequest<{}>) {
return Promise.resolve({
rpcId: request.rpcId,
result: { ok: true as const, value: { presets: [], authorable: false } },
result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } },
})
},
select(request: RpcRequest<{ agentPreset: string }>) {
@@ -206,13 +206,16 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
},
read(request: RpcRequest<{ agentPreset: string }>) {
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '', writable: true }
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' }
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
},
write(request: RpcRequest<{ agentPreset: string }>) {
copy(request: RpcRequest<{ from: string; agentPreset: string }>) {
const value = { agentPreset: request.payload.agentPreset }
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
},
openDocument(request: RpcRequest<{ agentPreset: string }>) {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } })
},
remove(request: RpcRequest<{ agentPreset: string }>) {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
},
@@ -364,19 +367,21 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const c = client()
// The whole domain crosses the carrier: the roster a picker reads, the
// per-session switch, and the three authoring calls the settings editor
// makes. Each has its own request schema, so a registration missing from
// either half fails here rather than in the browser.
// per-session switch, and the authoring calls the settings page makes.
// Each has its own request schema, so a registration missing from either
// half fails here rather than in the browser.
expect((await c.agentPresets.list({})).result).toEqual({
ok: true, value: { presets: [], authorable: false },
ok: true, value: { presets: [], authorable: false, hasDocument: false },
})
expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result)
.toEqual({ ok: true, value: { agentPreset: 'minimal' } })
expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '', writable: true },
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' },
})
expect((await c.agentPresets.write({ agentPreset: 'mine', content: '- id: x\n' })).result)
expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result)
.toEqual({ ok: true, value: { agentPreset: 'mine' } })
expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result)
.toEqual({ ok: true, value: { opened: true } })
expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} })
})

View File

@@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { release as osRelease } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
const signal = () => new AbortController().signal
@@ -287,3 +287,35 @@ describe('browser-renderable documents', () => {
])
})
})
describe('canOpenNativePath', () => {
it('always answers yes where the desktop is part of the platform', () => {
expect(canOpenNativePath({ platform: 'darwin', env: {} })).toBe(true)
expect(canOpenNativePath({ platform: 'win32', env: {} })).toBe(true)
})
it('requires a display server or WSL interop on linux', () => {
const linux = { platform: 'linux' as const, osRelease: '6.8.0-generic' }
// Headless is the case the capability exists for: `xdg-open` would spawn
// into nothing, so a surface should show the path as text instead.
expect(canOpenNativePath({ ...linux, env: {} })).toBe(false)
expect(canOpenNativePath({ ...linux, env: { DISPLAY: ':0' } })).toBe(true)
expect(canOpenNativePath({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe(true)
expect(canOpenNativePath({
platform: 'linux', osRelease: '5.15.153.1-microsoft-standard-WSL2', env: {},
})).toBe(true)
})
it('answers no on a platform the opener does not support', () => {
expect(canOpenNativePath({ platform: 'freebsd', env: {} })).toBe(false)
})
it('samples the ambient environment when no override is supplied', () => {
const env = process.env
const marked = (value: string | undefined): boolean => value !== undefined && value !== ''
const expected = marked(env.WSL_DISTRO_NAME) || marked(env.WSL_INTEROP)
|| marked(env.DISPLAY) || marked(env.WAYLAND_DISPLAY)
expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected)
})
})

View File

@@ -32,7 +32,9 @@ import {
commandListRequestSchema, commandListValueSchema,
} from '../src/api/commands.schema.ts'
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
import { agentPresetEntrySchema, agentPresetListValueSchema } from '../src/api/agent-presets.schema.ts'
import {
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
} from '../src/api/agent-presets.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
@@ -516,9 +518,17 @@ describe('agent-preset schemas', () => {
})
it('accepts an empty roster', () => {
// A deployment composing no presets still reports whether one could be
// written, so a surface knows to offer creation rather than nothing at all.
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false }))
.toEqual({ presets: [], authorable: false })
// A deployment composing no presets still reports its authoring and
// native-open capabilities, so a surface knows what to offer.
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false }))
.toEqual({ presets: [], authorable: false, hasDocument: false })
})
it('answers the open-document union by its discriminant', () => {
expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true })
expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' }))
.toEqual({ opened: false, path: '/presets/mine' })
// A closed reply must carry the path the surface shows instead.
expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow()
})
})