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:
Yichen Jiang
2026-08-08 11:41:47 +08:00
parent 68389b2a89
commit 5cf5e0cb76
17 changed files with 102 additions and 34 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/client/connection/README.md
README.md: 0392d51a929e2b92d9cd4b4728549250437631f1
README.zh.md: e82b25bf30717abbcd9ec460b5790277d3f9ce13
README.md: 974c04f2076f784e4c39c35fc779b7e36414f90c
README.zh.md: d20d196e11adad727fce6209d3b94594233ec3d7

View File

@@ -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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `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 发送 unaryrespond并为 `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 浏览器信任栅栏

View File

@@ -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',

View File

@@ -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.

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: 65e60e78bf82eeafeae2f3d49be290e93507d0ef
README.zh.md: 91b707fe86d31b12a045d07c891cd33d16d7439d
README.md: a18a4e4c123d70b44e2104d746dd416bacac8210
README.zh.md: 177af927aa1b0dc9c8ce49f4a0e8357cbb277760

View File

@@ -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.

View File

@@ -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` 而不是做差分。

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/preset/agent-presets/README.md
README.md: c61a84730a273052eeab31dc24696a7340621d4f
README.zh.md: 1caacec4f93990865e82c4498e64578eb149a3d6
README.md: cc019e79673c8441feaeb0509e0f333fcce17252
README.zh.md: 48950cc001811abd7cfcb2eac34e923f64886514

View File

@@ -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).

View 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`(组装文件的绝对路径)。

View File

@@ -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'] }],
)
}
/**

View File

@@ -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()