fix(agent-presets,connection): reclaim a deleted default, unpin a fence beside an open gate
Deleting the preset a user default names left the setting pointed at an id nothing will ever supply again, and every session created without an explicit pick then failed to start — the delete dialog called it 'new sessions cannot select it', which understates a hard creation error. `remove` now clears the user layer when it named the preset just deleted, exposing the deployment's own default underneath. Storing a default that does not exist YET stays deliberate: the roster is a live directory, so a name absent now may exist by the time a session asks, and `resolve` still reports that case. `agentPreset.select` also leaves the loopback set. It was pinned as a real escalation — one preset mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch left the same capability one method over. The deeper reason is that the capability is not the preset's to grant: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. `read`/`write`/`remove` stay pinned on their own footing — those touch files, not sessions.
This commit is contained in:
@@ -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/client/connection/README.md
|
||||
README.md: 0392d51a929e2b92d9cd4b4728549250437631f1
|
||||
README.zh.md: e82b25bf30717abbcd9ec460b5790277d3f9ce13
|
||||
README.md: 974c04f2076f784e4c39c35fc779b7e36414f90c
|
||||
README.zh.md: d20d196e11adad727fce6209d3b94594233ec3d7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.select`/`read`/`write`/`remove`, since 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; `agentPreset.list` stays out, carrying only ids and trust) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability; `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.select`/`read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上;`agentPreset.list` 不在其中,它只携带 id 与信任级别)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力;`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -57,12 +57,16 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
// A preset composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance and writing one is arbitrary capability — strictly more than
|
||||
// the settings document beside it. `agentPreset.select` joins them because
|
||||
// it can move a session from a two-tool preset onto one that edits the live
|
||||
// runtime, which is a real escalation even though every candidate is already
|
||||
// installed. `agentPreset.list` deliberately stays out: it carries ids and
|
||||
// trust only, like the model catalog, and a LAN client's picker needs it.
|
||||
'agentPreset.select',
|
||||
// the settings document beside it.
|
||||
//
|
||||
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
|
||||
// preset looks like escalation — one of them mounts the toolset that edits the
|
||||
// live runtime — but `session.create` already takes an `agentPreset`, so
|
||||
// pinning only the switch would leave the same capability one method over.
|
||||
// The deeper reason is that the capability is not the preset's to grant: the
|
||||
// deployment's own default already carries `bash` and the filesystem tools, so
|
||||
// any caller that may start a session at all can already run commands as this
|
||||
// process. Pinning the switch would be a fence beside an open gate.
|
||||
'agentPreset.read',
|
||||
'agentPreset.write',
|
||||
'agentPreset.remove',
|
||||
|
||||
@@ -139,9 +139,8 @@ describe('connection node half', () => {
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
// A composition names the plugins a session runs: reading one is
|
||||
// reconnaissance, writing one is arbitrary capability, and selecting one
|
||||
// can move a session onto a preset that edits the live runtime.
|
||||
'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
// reconnaissance and writing one is arbitrary capability.
|
||||
'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -230,7 +229,7 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
@@ -238,8 +237,11 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
// `agentPreset.list` joins the model catalog for the same reason: ids and
|
||||
// trust only, and a LAN client's preset picker needs it.
|
||||
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list']) {
|
||||
// trust only, and a LAN client's preset picker needs it. `select` is
|
||||
// reachable too: `session.create` already takes an `agentPreset`, and the
|
||||
// deployment's own default already carries bash, so pinning the switch
|
||||
// would be a fence beside an open gate.
|
||||
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
|
||||
@@ -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: 65e60e78bf82eeafeae2f3d49be290e93507d0ef
|
||||
README.zh.md: 91b707fe86d31b12a045d07c891cd33d16d7439d
|
||||
README.md: a18a4e4c123d70b44e2104d746dd416bacac8210
|
||||
README.zh.md: 177af927aa1b0dc9c8ce49f4a0e8357cbb277760
|
||||
|
||||
@@ -36,7 +36,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. Together with `select`, 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 writing one is arbitrary capability. `list` stays ordinary — it carries ids and trust, and every preset picker needs it — and reports `authorable`, whether the deployment configures a root a new preset could be written to at all.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -36,7 +36,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` 抵达调用方。连同 `select`,这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 保持为普通方法——它只携带 id 与信任级别,而每个 preset 选择器都需要它——并报告 `authorable`,即部署是否配置了可供写入新 preset 的根目录。
|
||||
`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 的根目录。
|
||||
|
||||
`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` 而不是做差分。
|
||||
|
||||
|
||||
@@ -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/preset/agent-presets/README.md
|
||||
README.md: c61a84730a273052eeab31dc24696a7340621d4f
|
||||
README.zh.md: 1caacec4f93990865e82c4498e64578eb149a3d6
|
||||
README.md: cc019e79673c8441feaeb0509e0f333fcce17252
|
||||
README.zh.md: 48950cc001811abd7cfcb2eac34e923f64886514
|
||||
|
||||
@@ -18,7 +18,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all.
|
||||
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
|
||||
- `ctx.agentPresets.write(id, content): Promise<void>` Create or replace a locally authored preset.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
|
||||
|
||||
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `ctx.agentPresets.authorable: boolean` 是否存在 `user` 信任级别的根目录,也即是否可能写入 preset。
|
||||
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容完全一致。
|
||||
- `ctx.agentPresets.write(id, content): Promise<void>` 创建或替换一个本地创作的 preset。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset。若用户默认值正指向刚被删除的这一个,则清除它:存下一个尚不存在的默认值是有意为之,但本次调用删掉的那个再也不会有人提供,留着它会让每个未显式指名的会话都无法开启。
|
||||
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
|
||||
import type { PresetMetadata } from './metadata.ts'
|
||||
@@ -83,6 +83,12 @@ export class AgentPresets extends Service {
|
||||
*/
|
||||
private settings: SettingsScope<AgentPresetSettings> | undefined
|
||||
|
||||
/**
|
||||
* The settings service behind {@link settings}, held for the one write this
|
||||
* service makes: clearing a user default it has just deleted.
|
||||
*/
|
||||
private settingsService: SettingsService | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
||||
@@ -96,7 +102,11 @@ export class AgentPresets extends Service {
|
||||
AgentPresetSettingsSchema,
|
||||
{ base: { default: config.default } },
|
||||
)
|
||||
settingsCtx.effect(() => () => { this.settings = undefined }, 'agentPresets.settings()')
|
||||
this.settingsService = settingsCtx.settings
|
||||
settingsCtx.effect(() => () => {
|
||||
this.settings = undefined
|
||||
this.settingsService = undefined
|
||||
}, 'agentPresets.settings()')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,6 +206,17 @@ export class AgentPresets extends Service {
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
// Storing a default that does not exist YET is deliberate — the roster is a
|
||||
// live directory, so a name absent now may exist by the time a session asks
|
||||
// for it, and `resolve` reports it then. A default this call just deleted is
|
||||
// not that case: nothing will ever supply it again, and left in place every
|
||||
// session created without an explicit pick would fail to start. Clearing it
|
||||
// exposes the deployment's own default underneath, which is the layering.
|
||||
if (this.settings?.get().default !== id) return
|
||||
await this.settingsService?.mutate(
|
||||
settingsNamespace(SETTINGS_NAMESPACE),
|
||||
[{ op: 'unset', path: ['default'] }],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* so a person can change which preset new sessions get without a restart.
|
||||
*/
|
||||
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
@@ -20,7 +20,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }]
|
||||
@@ -30,7 +30,9 @@ const NS = settingsNamespace(SETTINGS_NAMESPACE)
|
||||
* A composition with a real file-backed settings provider. `settingsFiber` is
|
||||
* the provider's own handle, so a test can take it away the way a reload does.
|
||||
*/
|
||||
async function harness(): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
|
||||
async function harness(
|
||||
extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [],
|
||||
): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-'))
|
||||
const settingsFile = join(home, 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
@@ -47,7 +49,7 @@ async function harness(): Promise<{ ctx: Context; settingsFile: string; settings
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false })
|
||||
await settingsFiber
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] })
|
||||
return { ctx, settingsFile, settingsFiber }
|
||||
}
|
||||
|
||||
@@ -114,6 +116,26 @@ describe('the default preset as a user setting', () => {
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
it('clears a user default it has just deleted', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-'))
|
||||
await mkdir(join(root, 'mine'))
|
||||
await writeFile(
|
||||
join(root, 'mine', COMPOSITION_FILE),
|
||||
`- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`,
|
||||
)
|
||||
const { ctx } = await harness([{ path: root, trust: 'user' as const }])
|
||||
await ctx.settings.update(NS, { default: 'mine' })
|
||||
expect(ctx.agentPresets.defaultId).toBe('mine')
|
||||
|
||||
await ctx.agentPresets.remove('mine')
|
||||
|
||||
// Nothing will ever supply that id again, so leaving the setting pointed at
|
||||
// it would fail every session created without an explicit pick. Clearing it
|
||||
// exposes the deployment's own default underneath.
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
expect((await ctx.agentPresets.resolve()).id).toBe('standard')
|
||||
})
|
||||
|
||||
it('reports an unknown user default only when a session tries to use it', async () => {
|
||||
const { ctx } = await harness()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user