Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
_Kerman
2026-08-05 16:21:09 +08:00
71 changed files with 1102 additions and 156 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: 552c431e9b82d64d7715e80dc539bcdf2e5c6f49
README.zh.md: 62086281b50373ac49a59fe9fbb380543d64e660
README.md: e34ba29caf60708272147d373fe1e86a2f826510
README.zh.md: 9e308b96df57635f922935749b649edc063b3ea3

View File

@@ -36,7 +36,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
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.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
## Carrier layer (`/client` + root)

View File

@@ -36,7 +36,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`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` 而不是做差分。
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)

View File

@@ -68,7 +68,7 @@ import type {
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -337,6 +337,8 @@ export interface ApiProxyDefaults {
workspaceRoot: string
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
}
/** The tool/call payload fields the presenter path reads. */
@@ -1291,6 +1293,48 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
}
/** Open one Host-resolved target and map native failures onto the wire vocabulary. */
async function openTarget(
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
open: (path: string, signal: AbortSignal) => Promise<void>,
): Promise<RpcResponse<{ opened: true }>> {
try {
await open(path, signal)
return ok(request, { opened: true as const })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'path open was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
}
/** Open one Host-resolved path with its default application. */
function openPath(
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>> {
const open = defaults.openPath
?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal))
return openTarget(request, path, signal, open)
}
/** Open one Host-resolved text document in a native editor. */
function openTextFile(
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>> {
const open = defaults.openTextFile
?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal))
return openTarget(request, path, signal, open)
}
/** 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: {} }
@@ -2181,25 +2225,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async openPath(request, signal) {
try {
const open = defaults.openPath
?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
await open(request.payload.path, signal)
return ok(request, { opened: true as const })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'path open was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
return openPath(request, request.payload.path, signal)
},
},
@@ -2346,11 +2372,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const exposed = exposedNamespaces()
return Promise.resolve(ok(request, {
writable: settings.writable,
hasDocument: settings.documentPath !== undefined,
namespaces: settings.describe({ redactSecrets: true })
.filter(descriptor => exposed.has(String(descriptor.ns)))
.map(namespaceView),
}))
},
async openDocument(request, signal) {
const settings = ctx.get('settings')
if (settings === undefined) return err(request, settingsAbsent())
if (isAborted(signal)) {
return err(request, {
code: 'cancelled',
message: 'settings document open was aborted',
details: {},
})
}
let path: string | undefined
try {
path = await settings.prepareDocument()
} catch (error: unknown) {
if (isAborted(signal)) {
return err(request, {
code: 'cancelled',
message: 'settings document preparation was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
if (path === undefined) {
return err(request, {
code: 'internal',
message: 'settings provider has no local document to open',
details: {},
})
}
if (isAborted(signal)) {
return err(request, {
code: 'cancelled',
message: 'settings document open was aborted',
details: {},
})
}
return openTextFile(request, path, signal)
},
update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),

View File

@@ -57,6 +57,7 @@ export interface RpcMethodMap {
'goal.complete': GoalsApi['complete']
'goal.clear': GoalsApi['clear']
'settings.describe': SettingsApi['describe']
'settings.openDocument': SettingsApi['openDocument']
'settings.update': SettingsApi['update']
'settings.replace': SettingsApi['replace']
'settings.mutate': SettingsApi['mutate']

View File

@@ -32,9 +32,18 @@ export const settingsDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wi
/** settings.describe response value. */
export const settingsDescribeValueSchema = z.object({
writable: z.boolean(),
hasDocument: z.boolean(),
namespaces: z.array(settingsNamespaceViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'settings.describe'>>>
/** settings.openDocument request payload. */
export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>>
/** settings.openDocument response value. */
export const settingsOpenDocumentValueSchema = z.object({
opened: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>>
/** settings.update request payload. */
export const settingsUpdateRequestSchema = z.object({
ns: z.string().min(1),

View File

@@ -53,10 +53,26 @@ export type SettingsPathOpView =
export interface SettingsApi {
/**
* Describe every registered namespace: redacted layered values plus the
* serialized schema a client renders its form from. `writable: false`
* (read-only provider) tells the client to disable every write control.
* serialized schema a client renders its form from. `hasDocument` reports
* whether a file-backed provider owns a local document without exposing its
* Host path. This method is loopback-only; `writable: false` (read-only
* provider) tells the client to disable every write control.
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{ writable: boolean; namespaces: SettingsNamespaceView[] }>>
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
writable: boolean
hasDocument: boolean
namespaces: SettingsNamespaceView[]
}>>
/**
* Materialize the configured local document when absent and ask the Host to
* hand it to the platform text-document opener. macOS forces a text editor;
* Linux and Windows use the desktop file association. The request carries
* no path, so the browser cannot choose an arbitrary Host filesystem target.
*/
openDocument(
request: RpcRequest<{}>, signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>>
/**
* Merge a patch into one namespace's user layer (validate → persist →

View File

@@ -49,7 +49,8 @@ import {
goalClearValueSchema,
} from '../api/goals.schema.ts'
import {
settingsDescribeValueSchema, settingsMutateValueSchema, settingsReplaceValueSchema, settingsUpdateValueSchema,
settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema,
settingsReplaceValueSchema, settingsUpdateValueSchema,
} from '../api/settings.schema.ts'
import {
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
@@ -132,6 +133,7 @@ export interface IApiClient {
}
settings: {
describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>>
update(payload: RequestPayload<'settings.update'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.update'>>>
replace(payload: RequestPayload<'settings.replace'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.replace'>>>
mutate(payload: RequestPayload<'settings.mutate'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.mutate'>>>
@@ -189,6 +191,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'goal.complete': goalCompleteValueSchema,
'goal.clear': goalClearValueSchema,
'settings.describe': settingsDescribeValueSchema,
'settings.openDocument': settingsOpenDocumentValueSchema,
'settings.update': settingsUpdateValueSchema,
'settings.replace': settingsReplaceValueSchema,
'settings.mutate': settingsMutateValueSchema,
@@ -449,6 +452,7 @@ export abstract class AbstractApiClient implements IApiClient {
readonly settings: IApiClient['settings'] = {
describe: (payload, signal) => this.callUnary('settings.describe', payload, signal),
openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal),
update: (payload, signal) => this.callUnary('settings.update', payload, signal),
replace: (payload, signal) => this.callUnary('settings.replace', payload, signal),
mutate: (payload, signal) => this.callUnary('settings.mutate', payload, signal),

View File

@@ -51,7 +51,8 @@ import {
goalClearRequestSchema,
} from '../api/goals.schema.ts'
import {
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsReplaceRequestSchema, settingsUpdateRequestSchema,
settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema,
settingsReplaceRequestSchema, settingsUpdateRequestSchema,
} from '../api/settings.schema.ts'
import {
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
@@ -69,9 +70,8 @@ import {
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
* documented on Wire); the dispatch point carries the one Wire→exact cast.
* Every invoke receives the carrier Request's signal; methods whose contract
* declares a signal parameter (session.search and command.execute) forward it,
* the rest ignore it.
* Every invoke receives the carrier Request's signal; routes whose contract
* declares a signal parameter forward it, and the rest ignore it.
*/
type UnaryRoutes = {
[K in keyof RpcMethodMap]: {
@@ -116,6 +116,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
'settings.replace': { schema: settingsReplaceRequestSchema, invoke: (api, r) => api.settings.replace(r) },
'settings.mutate': { schema: settingsMutateRequestSchema, invoke: (api, r) => api.settings.mutate(r) },

View File

@@ -1,4 +1,4 @@
/** Cross-platform open-with-default-application used by the local GUI carrier. */
/** Cross-platform native path and text-document openers used by the local GUI carrier. */
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
@@ -11,27 +11,26 @@ export interface PathOpenerInternals {
run?: PathOpenerRunner
}
/** Native path-open intent; macOS distinguishes text editing from file association. */
type PathOpenIntent = 'default' | 'text-editor'
/** PowerShell single-quoted literal (doubles embedded quotes). */
function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/**
* Open a filesystem path with the operating system's default application.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
*/
export async function openNativePath(
/** Dispatch one shell-free platform command for the requested open intent. */
async function openNativePathWithIntent(
path: string,
signal: AbortSignal,
intent: PathOpenIntent,
internals: PathOpenerInternals = {},
): Promise<void> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runNativeCommand
if (platform === 'darwin') {
await run('open', [path], signal)
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
return
}
@@ -51,3 +50,32 @@ export async function openNativePath(
throw new Error(`native path opener is unsupported on ${platform}`)
}
/**
* Open a filesystem path with the operating system's default application.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
*/
export function openNativePath(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
return openNativePathWithIntent(path, signal, 'default', internals)
}
/**
* Open a text document for editing; macOS bypasses the file-type association
* so a YAML association with a browser cannot consume the gesture.
* @param path - absolute or host-resolvable text-document path.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
*/
export function openNativeTextFile(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
return openNativePathWithIntent(path, signal, 'text-editor', internals)
}

View File

@@ -5,7 +5,7 @@
* invalidation frames (settings/credentials/models changed).
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -47,18 +47,35 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
class MemorySettings extends Settings {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) {
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
preparedPath?: string
}) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
this.readOnly = options?.readOnly ?? false
this.path = options?.documentPath
this.preparedPath = options?.preparedPath
}
private readonly readOnly: boolean
private readonly path: string | undefined
private readonly preparedPath: string | undefined
get writable(): boolean {
return !this.readOnly
}
override get documentPath(): string | undefined {
return this.path
}
override prepareDocument(): Promise<string | undefined> {
return Promise.resolve(this.preparedPath ?? this.documentPath)
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
@@ -146,7 +163,12 @@ const AdapterConfig = z.object({
})
async function harness(options?: {
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
settings?: false | {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
preparedPath?: string
}
credentials?: false | { shadowed?: string[] }
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
configurableProviders?: false
@@ -205,11 +227,15 @@ describe('settings domain', () => {
})
it('describes layered redacted namespaces with their secret slots', async () => {
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } })
const ctx = await harness({ settings: {
doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } },
documentPath: '/tmp/custom-settings.yaml',
} })
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.writable).toBe(true)
expect(value.hasDocument).toBe(true)
expect(value.namespaces).toHaveLength(1)
const view = value.namespaces[0]!
expect(view.ns).toBe('llm-deepseek')
@@ -222,6 +248,62 @@ describe('settings domain', () => {
expect(JSON.stringify(value)).not.toContain('user-secret')
})
it('opens the provider-resolved document without accepting a browser path', async () => {
const ctx = await harness({ settings: {
documentPath: '/tmp/described-settings.yaml',
preparedPath: '/tmp/custom-settings.yaml',
} })
const opened: string[] = []
const api = createApiProxy(ctx, {
...DEFAULTS,
openTextFile: (path) => {
opened.push(path)
return Promise.resolve()
},
})
expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal)))
.toEqual({ opened: true })
expect(opened).toEqual(['/tmp/custom-settings.yaml'])
})
it('refuses to open settings when the provider has no local document', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false)
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
expect(error.code).toBe('internal')
expect(error.message).toContain('no local document')
})
it('does not prepare or open a settings document after cancellation', async () => {
const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } })
const opened: string[] = []
const api = createApiProxy(ctx, {
...DEFAULTS,
openTextFile: (path) => {
opened.push(path)
return Promise.resolve()
},
})
const prepare = vi.spyOn(ctx.settings, 'prepareDocument')
const cancelled = new AbortController()
cancelled.abort()
expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code)
.toBe('cancelled')
expect(prepare).not.toHaveBeenCalled()
const pending = Promise.withResolvers<string | undefined>()
prepare.mockReturnValueOnce(pending.promise)
const duringPrepare = new AbortController()
const opening = api.settings.openDocument(request({}), duringPrepare.signal)
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
duringPrepare.abort()
pending.resolve('/tmp/settings.yaml')
expect(expectErr(await opening).code).toBe('cancelled')
expect(opened).toEqual([])
})
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane remains opt-in, so a

View File

@@ -96,7 +96,8 @@ function scriptedApi(overrides: {
...overrides.goals,
},
settings: {
describe: r => ok(r, { writable: true, namespaces: [] }),
describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
openDocument: r => ok(r, { opened: true as const }),
update: err,
replace: err,
mutate: err,
@@ -679,7 +680,8 @@ describe('config unary surface', () => {
const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
const api = scriptedApi({
settings: {
describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })),
describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
update: record('settings.update', r => ok(r, view)),
replace: record('settings.replace', r => ok(r, view)),
mutate: record('settings.mutate', r => ok(r, view)),
@@ -697,7 +699,8 @@ describe('config unary surface', () => {
const c = client(api)
const described = await c.settings.describe({})
expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(updated.result).toEqual({ ok: true, value: view })
const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
@@ -718,14 +721,14 @@ describe('config unary surface', () => {
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
expect(seen.map(call => call.method)).toEqual([
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.providers', 'llm.models',
])
expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[3]?.payload)
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[4]?.payload)
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
})
it('rejects an invalid credential reference name at the carrier boundary', async () => {

View File

@@ -220,7 +220,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
settings: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
},
async openDocument(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
async update(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }

View File

@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
const signal = () => new AbortController().signal
@@ -26,6 +26,18 @@ describe('native path opener', () => {
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
})
it('bypasses macOS file associations for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run })
expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal))
})
it('uses the Linux desktop association for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
})
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
@@ -36,6 +48,16 @@ describe('native path opener', () => {
)
})
it('uses the Windows desktop association for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run })
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"],
expect.any(AbortSignal),
)
})
it('opens with Linux xdg-open', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })