feat(web): open the local settings file

This commit is contained in:
Yichen Jiang
2026-08-04 16:33:35 +08:00
parent 828cb2ae20
commit e31b7221e7
61 changed files with 939 additions and 98 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: ed04a3431e4c9114b3115119c1e69d378cec77ae
README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7
README.md: f7a3113e25b570d6aa63e0ce4b25d8b143807dfc
README.zh.md: da1a2a0347150584d0dd040caf92316f5da31837

View File

@@ -38,7 +38,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. `command.*` addresses an ordinary session's Agent and resumes a cold ordinary session when needed, 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 provider's optional absolute `documentPath`. The browser uses that path only as availability metadata: 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.
The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint, replaces corpus activity with the exact child Agent driver's running state, and includes an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).

View File

@@ -38,7 +38,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。`command.*` 寻址普通会话的 Agent,并在需要时恢复冷态普通会话;`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`,以及提供方可选的绝对 `documentPath`。浏览器仅将该路径用作可用性元数据:无路径参数的 `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` 错误应答这些领域。
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。

View File

@@ -70,7 +70,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
@@ -356,6 +356,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. */
@@ -1402,6 +1404,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: {} }
@@ -2280,25 +2324,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)
},
},
@@ -2445,11 +2471,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const exposed = exposedNamespaces()
return Promise.resolve(ok(request, {
writable: settings.writable,
...settings.documentPath === undefined ? {} : { documentPath: settings.documentPath },
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(),
documentPath: z.string().min(1).optional(),
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,27 @@ 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. `documentPath` is the
* absolute Host path only for a file-backed provider; this method is
* loopback-only, and clients use it only to decide whether to offer the
* pathless `openDocument` action instead of attempting browser filesystem
* access. `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
documentPath?: string
namespaces: SettingsNamespaceView[]
}>>
/**
* Materialize the configured local document when absent and ask the Host to
* open it with the platform default application. 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

@@ -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.documentPath).toBe('/tmp/custom-settings.yaml')
expect(value.namespaces).toHaveLength(1)
const view = value.namespaces[0]!
expect(view.ns).toBe('llm-deepseek')
@@ -222,6 +248,33 @@ 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)
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
expect(error.code).toBe('internal')
expect(error.message).toContain('no local document')
})
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

@@ -97,6 +97,7 @@ function scriptedApi(overrides: {
},
settings: {
describe: r => ok(r, { writable: true, namespaces: [] }),
openDocument: r => ok(r, { opened: true as const }),
update: err,
replace: err,
mutate: err,
@@ -680,6 +681,7 @@ describe('config unary surface', () => {
const api = scriptedApi({
settings: {
describe: record('settings.describe', r => ok(r, { writable: true, 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)),
@@ -698,6 +700,7 @@ describe('config unary surface', () => {
const described = await c.settings.describe({})
expect(described.result).toEqual({ ok: true, value: { writable: true, 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

@@ -222,6 +222,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, 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 })