Merge branch 'master' into pr/adapter-registration-race
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/ui/app-boot/README.md
|
||||
README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7
|
||||
README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e
|
||||
README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3
|
||||
README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42
|
||||
|
||||
@@ -10,16 +10,16 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin.
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution.
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。
|
||||
|
||||
|
||||
@@ -160,16 +160,46 @@ export interface FailLoudProcess {
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
|
||||
// exact reasons already folded into the boot diagnostic visible through the
|
||||
// next process rejection checkpoint so the process guard can coalesce them.
|
||||
const assembledActivationRejections = new Map<unknown, number>()
|
||||
|
||||
function retainAssembledRejection(reason: unknown): void {
|
||||
assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function releaseAssembledRejection(reason: unknown): void {
|
||||
const count = assembledActivationRejections.get(reason)
|
||||
if (count === undefined || count === 1) {
|
||||
assembledActivationRejections.delete(reason)
|
||||
} else {
|
||||
assembledActivationRejections.set(reason, count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
|
||||
for (const reason of reasons) retainAssembledRejection(reason)
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
} finally {
|
||||
for (const reason of reasons) releaseAssembledRejection(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install before boot to turn a late unhandled plugin-init rejection into one
|
||||
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
|
||||
* the returned function removes the handler.
|
||||
* labelled stderr diagnostic and `exit(1)`. A rejection already included by
|
||||
* {@link assertEntriesActivated} is ignored during its process checkpoint;
|
||||
* every other rejection remains fatal. Stdout remains untouched for ACP; the
|
||||
* returned function removes the handler.
|
||||
* @param binName - the diagnostic prefix on the fatal-failure line.
|
||||
* @param proc - the process slice to register on; tests inject a fake.
|
||||
* @returns the uninstaller that removes the rejection handler.
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
const handler = (err: unknown): void => {
|
||||
if (assembledActivationRejections.has(err)) return
|
||||
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
proc.exit(1)
|
||||
}
|
||||
@@ -192,28 +222,64 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
/**
|
||||
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
||||
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
|
||||
* `packages/client/web/src/loader-status.ts`.
|
||||
*/
|
||||
const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
const FIBER_FAILED = 3 as FiberState.FAILED
|
||||
|
||||
/** Render a thrown plugin value without discarding an Error's original stack. */
|
||||
function formatActivationError(error: unknown): string {
|
||||
return error instanceof Error ? error.stack ?? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
|
||||
* @param ctx - The settled application root.
|
||||
* @param binName - Diagnostic prefix.
|
||||
* Reject a settled Loader tree when an enabled entry failed or remains inactive.
|
||||
* Plugin failures include the original thrown stack; pending entries name their
|
||||
* unresolved services because no plugin error exists for that state. Active
|
||||
* entries require no further wait; only failed fibers are awaited to recover
|
||||
* their private rejection reason.
|
||||
* @param ctx - the settled context whose Loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @returns nothing when every enabled entry is active.
|
||||
* @throws after one process rejection checkpoint when an entry failed to
|
||||
* import, rejected during activation, or did not become active.
|
||||
*/
|
||||
export function assertEntriesActive(ctx: Context, binName: string): void {
|
||||
export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
const failures: string[] = []
|
||||
const rejectionReasons: unknown[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
|
||||
if (entry.fiber.state === FIBER_PENDING) {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
const fiber = entry.fiber
|
||||
if (fiber === undefined || entry.disabled) continue
|
||||
const state = fiber.state
|
||||
if (state === FIBER_ACTIVE) continue
|
||||
if (state === FIBER_FAILED) {
|
||||
try {
|
||||
await fiber.await()
|
||||
} catch (error) {
|
||||
rejectionReasons.push(error)
|
||||
failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (state === FIBER_PENDING) {
|
||||
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
|
||||
const subject = missing.length === 1 ? 'service' : 'services'
|
||||
failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
|
||||
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
if (rejectionReasons.length > 0) {
|
||||
await observeLoaderRejectionCheckpoint(rejectionReasons)
|
||||
}
|
||||
const noun = failures.length === 1 ? 'entry' : 'entries'
|
||||
throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,9 +291,13 @@ export function assertEntriesActive(ctx: Context, binName: string): void {
|
||||
* bootstrap include is therefore statically imported and mounted as the
|
||||
* `cordis:include` builtin, loading through the ambient module pipeline
|
||||
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
|
||||
* config-relative. A missing fiber rejects here; a later init rejection is
|
||||
* handled by {@link installFailLoud}. Built bins need the Loader's native
|
||||
* helper for bare plugin specifiers; relative specifiers do not.
|
||||
* config-relative. The package build embeds Include while leaving Loader
|
||||
* external, so the built include tree and host share one Loader peer. A
|
||||
* missing fiber rejects here; a later init rejection is rethrown with its
|
||||
* original stack by {@link assertEntriesActivated}; later unhandled
|
||||
* rejections remain covered by {@link installFailLoud}. Built bins need the
|
||||
* Loader's native helper for bare plugin specifiers; relative specifiers do
|
||||
* not.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
@@ -259,12 +329,11 @@ export async function boot(
|
||||
// A surface can finish and dispose the whole tree while that await is still
|
||||
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
|
||||
// typed before the last entry settles tears the context down under us. The
|
||||
// Loader service goes with it, and both assertions below describe a live
|
||||
// Loader service goes with it, and the activation audit describes a live
|
||||
// tree — reading `ctx.loader` here would throw a TypeError over an app that
|
||||
// exited exactly as asked.
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
assertEntriesActive(ctx, binName)
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -135,6 +135,33 @@ describe('installFailLoud', () => {
|
||||
uninstallReal()
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before)
|
||||
})
|
||||
|
||||
it('does not report an activation rejection shared by entries in the boot audit', async () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('assembled activation failure')
|
||||
const audit = assertEntriesActivated({
|
||||
loader: {
|
||||
entries: () => ['broken-a', 'broken-b'].map(name => ({
|
||||
options: { name },
|
||||
fiber: {
|
||||
state: 3,
|
||||
inject: {},
|
||||
ctx: { get: () => undefined },
|
||||
await: async () => { throw error },
|
||||
},
|
||||
})),
|
||||
},
|
||||
} as unknown as Context, NAME)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.written).toEqual([])
|
||||
expect(proc.exits).toEqual([])
|
||||
await expect(audit).rejects.toThrow('assembled activation failure')
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
@@ -157,6 +184,97 @@ describe('assertEntriesLoaded', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesActivated', () => {
|
||||
interface FakeFiber {
|
||||
state: number
|
||||
inject: Record<string, unknown>
|
||||
ctx: { get(name: string): unknown }
|
||||
await(): Promise<unknown>
|
||||
}
|
||||
|
||||
const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
|
||||
loader: { entries: () => entries },
|
||||
}) as unknown as Context
|
||||
|
||||
const fiber = (
|
||||
state: number,
|
||||
error?: unknown,
|
||||
inject: Record<string, unknown> = {},
|
||||
services: string[] = [],
|
||||
): FakeFiber => ({
|
||||
state,
|
||||
inject,
|
||||
ctx: { get: name => services.includes(name) ? {} : undefined },
|
||||
await: error === undefined ? async () => undefined : async () => { throw error },
|
||||
})
|
||||
|
||||
it('passes active entries and ignores disabled entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const active = fiber(2)
|
||||
active.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
const disabled = fiber(3, new Error('disabled failure'))
|
||||
disabled.await = async () => {
|
||||
awaitCalls++
|
||||
throw new Error('disabled failure')
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: active, options: { name: 'active' } },
|
||||
{ fiber: disabled, disabled: true, options: { name: 'disabled' } },
|
||||
]), NAME)).resolves.toBeUndefined()
|
||||
expect(awaitCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('reports the plugin name and original activation stack instead of fiber state 3', async () => {
|
||||
const original = new Error('actual plugin failure')
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, original), options: { name: 'broken-plugin' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`)
|
||||
})
|
||||
|
||||
it('formats stackless and non-Error activation failures', async () => {
|
||||
const stackless = new Error('stackless failure')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(3, stackless), options: { name: 'stackless' } },
|
||||
{ fiber: fiber(3, 'plain failure'), options: { name: 'plain' } },
|
||||
]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`)
|
||||
})
|
||||
|
||||
it('reports unresolved services for pending entries', async () => {
|
||||
let awaitCalls = 0
|
||||
const expected = [
|
||||
`${NAME}: 3 entries did not activate`,
|
||||
'waiting: pending (waiting for services: missingA, missingB)',
|
||||
'single-wait: pending (waiting for service: missing)',
|
||||
'unknown-wait: pending (waiting for services: unknown)',
|
||||
].join('\n')
|
||||
const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready'])
|
||||
const singleWait = fiber(0, undefined, { missing: {} })
|
||||
const unknownWait = fiber(0)
|
||||
for (const item of [waiting, singleWait, unknownWait]) {
|
||||
item.await = async () => {
|
||||
awaitCalls++
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: waiting, options: { name: 'waiting' } },
|
||||
{ fiber: singleWait, options: { name: 'single-wait' } },
|
||||
{ fiber: unknownWait, options: { name: 'unknown-wait' } },
|
||||
]), NAME)).rejects.toThrow(expected)
|
||||
expect(awaitCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the numeric diagnostic for a settled unexpected state', async () => {
|
||||
await expect(assertEntriesActivated(ctxWith([
|
||||
{ fiber: fiber(4), options: { name: 'disposed' } },
|
||||
]), NAME)).rejects.toThrow('disposed: fiber state 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOverlayPatches', () => {
|
||||
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
|
||||
const dir = tmp()
|
||||
@@ -232,31 +350,14 @@ describe('boot', () => {
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
|
||||
it('rejects a settled tree with a pending inject and names every missing service', async () => {
|
||||
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
|
||||
writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
|
||||
})
|
||||
|
||||
it('uses singular diagnostics for one missing pending dependency', () => {
|
||||
const ctx = {
|
||||
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
|
||||
})
|
||||
|
||||
it('reports unknown pending dependencies and unexpected fiber states', () => {
|
||||
const entries = [
|
||||
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
|
||||
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
|
||||
]
|
||||
const ctx = {
|
||||
loader: { entries: () => entries },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([
|
||||
`${NAME}: 1 entry did not activate`,
|
||||
'./waiting.mjs: pending (waiting for service: neverProvided)',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
19
packages/ui/app-boot/tsdown.config.ts
Normal file
19
packages/ui/app-boot/tsdown.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Embed Include while keeping Loader external so the built include tree and
|
||||
* app host bind to one Loader peer.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
deps: {
|
||||
alwaysBundle: ['@cordisjs/plugin-include'],
|
||||
},
|
||||
})
|
||||
@@ -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/ui/permission/README.md
|
||||
README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4
|
||||
README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa
|
||||
README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a
|
||||
README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184
|
||||
|
||||
@@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
|
||||
|
||||
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
|
||||
|
||||
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
|
||||
|
||||
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
|
||||
|
||||
@@ -23,3 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
|
||||
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
|
||||
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
|
||||
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
|
||||
|
||||
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
|
||||
|
||||
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
|
||||
|
||||
@@ -23,3 +25,4 @@
|
||||
- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
|
||||
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
|
||||
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。
|
||||
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -58,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
@@ -68,6 +69,9 @@ export interface PresetSpec {
|
||||
*/
|
||||
export const CUSTOM_PRESET = 'custom'
|
||||
|
||||
/** Settings namespace carrying the default for future sessions. */
|
||||
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
|
||||
|
||||
/**
|
||||
* Fold the last selected preset from the durable log; replay needs no catch-up
|
||||
* state.
|
||||
@@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
|
||||
return state
|
||||
}
|
||||
|
||||
/** The {@link PermissionService} config: the deployment's preset table. */
|
||||
/** User setting resolved when a new session receives its initial permission. */
|
||||
export interface PermissionSettings {
|
||||
/** Preset pinned into a newly created session. */
|
||||
defaultPreset: string
|
||||
}
|
||||
|
||||
/** The {@link PermissionService} config: preset table and composition default. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The preset table: name → knob bundle. Defaults to `workspace-write`
|
||||
@@ -134,6 +144,11 @@ export interface Config {
|
||||
* never). The name `custom` is reserved for the derived not-a-preset state.
|
||||
*/
|
||||
presets?: Record<string, PresetSpec>
|
||||
/**
|
||||
* Default for new sessions. When omitted, the preset matching the composed
|
||||
* sandbox and approval defaults is used.
|
||||
*/
|
||||
defaultPreset?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,11 +174,13 @@ export class PermissionService extends Service {
|
||||
name: 'danger-full-access', description: 'Full file access without approval prompts.',
|
||||
},
|
||||
}),
|
||||
defaultPreset: z.string(),
|
||||
})
|
||||
|
||||
static inject = ['bash', 'approval']
|
||||
static inject = ['bash', 'approval', 'sessions']
|
||||
|
||||
private readonly presets: Record<string, PresetSpec>
|
||||
private defaultSettings: () => PermissionSettings
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'permission')
|
||||
@@ -175,6 +192,37 @@ export class PermissionService extends Service {
|
||||
if (ctx.bash.sandboxMode === undefined) {
|
||||
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
|
||||
}
|
||||
const inferredDefault = this.derive(EMPTY_KNOBS)
|
||||
const defaultPreset = config.defaultPreset ?? inferredDefault
|
||||
if (defaultPreset === CUSTOM_PRESET) {
|
||||
throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly')
|
||||
}
|
||||
this.resolve(defaultPreset)
|
||||
const baseSettings: PermissionSettings = { defaultPreset }
|
||||
this.defaultSettings = () => baseSettings
|
||||
const presetChoices = this.names.map((name) => {
|
||||
const choice = z.const(name)
|
||||
const label = this.presets[name]?.name
|
||||
return label === undefined ? choice : choice.description(label)
|
||||
})
|
||||
const settingsSchema: z<PermissionSettings> = z.object({
|
||||
defaultPreset: z.union(presetChoices).required(),
|
||||
})
|
||||
installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, {
|
||||
setSource: (current) => {
|
||||
this.defaultSettings = current
|
||||
},
|
||||
// The source thunk reads the latest scope snapshot at session creation;
|
||||
// no process-level registration needs replacement on change.
|
||||
onChange: () => {},
|
||||
})
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
this.pinInitialPermission(session)
|
||||
})
|
||||
for (const session of ctx.sessions.list()) {
|
||||
this.pinInitialPermission(session)
|
||||
}
|
||||
|
||||
// The permissions projection unit: fold the three whole-value knob
|
||||
// events; view derives the select over the composition defaults this
|
||||
@@ -237,6 +285,15 @@ export class PermissionService extends Service {
|
||||
return Object.keys(this.presets)
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset currently selected as the default for future sessions.
|
||||
* @returns the resolved settings value, or the composition default without
|
||||
* a mounted settings provider.
|
||||
*/
|
||||
get defaultPreset(): string {
|
||||
return this.defaultSettings().defaultPreset
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preset matching the effective knob values. A still-matching
|
||||
* last selection wins shared-bundle ties; otherwise the first table match
|
||||
@@ -328,6 +385,44 @@ export class PermissionService extends Service {
|
||||
setApprovalPolicy(session, spec.approval)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every missing permission fact before a session is published. A
|
||||
* genuinely fresh session uses the current user default; seeded or partially
|
||||
* initialized sessions preserve their effective knob values and only gain
|
||||
* the missing durable facts.
|
||||
*/
|
||||
private pinInitialPermission(session: Session): void {
|
||||
const events = session.events
|
||||
const selected = effectivePermissionPreset(events)
|
||||
const sandbox = effectiveSandboxMode(events)
|
||||
const approval = effectiveApprovalPolicy(events)
|
||||
const seeded = events.some(event => event.type === 'session/end-seed')
|
||||
if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
|
||||
const name = this.defaultPreset
|
||||
const spec = this.resolve(name)
|
||||
session.append('permission/preset', { preset: name })
|
||||
setSandboxMode(session, spec.sandbox)
|
||||
setApprovalPolicy(session, spec.approval)
|
||||
return
|
||||
}
|
||||
|
||||
const state: KnobState = {
|
||||
preset: selected ?? null,
|
||||
sandbox: sandbox ?? null,
|
||||
approval: approval ?? null,
|
||||
}
|
||||
const effective = this.derive(state)
|
||||
if (selected === undefined && effective !== CUSTOM_PRESET) {
|
||||
session.append('permission/preset', { preset: effective })
|
||||
}
|
||||
if (sandbox === undefined) {
|
||||
setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode)
|
||||
}
|
||||
if (approval === undefined) {
|
||||
setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PermissionService
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission'
|
||||
import PermissionService, {
|
||||
CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE,
|
||||
} from '@deepseek-ai/dsh-permission'
|
||||
import type { Config } from '@deepseek-ai/dsh-permission'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
|
||||
/** Writable memory provider for the permission/settings lifecycle specs. */
|
||||
class MemorySettings extends Settings {
|
||||
readonly doc: Record<string, unknown> = {}
|
||||
readonly writable = true
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc[ns] = structuredClone(section)
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function mounted(options: {
|
||||
config?: Config
|
||||
@@ -12,6 +31,7 @@ async function mounted(options: {
|
||||
approvalDefault?: ApprovalPolicy | undefined
|
||||
} = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
|
||||
resolve() { throw new Error('permission tests do not execute bash') },
|
||||
@@ -27,6 +47,23 @@ function freshSession(id: string): Session {
|
||||
return new Session(SessionId(id))
|
||||
}
|
||||
|
||||
async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(MemorySettings)
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: 'workspace-write',
|
||||
resolve() { throw new Error('permission tests do not execute bash') },
|
||||
run() { throw new Error('permission tests do not execute bash') },
|
||||
start() { throw new Error('permission tests do not execute bash') },
|
||||
})
|
||||
ctx.provide('approval', {
|
||||
config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' },
|
||||
})
|
||||
await ctx.plugin(PermissionService, {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('effectivePermissionPreset', () => {
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
const session = freshSession('sess-fold')
|
||||
@@ -66,8 +103,11 @@ describe('PermissionService', () => {
|
||||
expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
|
||||
})
|
||||
|
||||
it('composition defaults outside the table derive custom at zero events', async () => {
|
||||
const ctx = await mounted({ approvalDefault: 'never' })
|
||||
it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => {
|
||||
const ctx = await mounted({
|
||||
approvalDefault: 'never',
|
||||
config: { defaultPreset: 'workspace-write' },
|
||||
})
|
||||
const session = freshSession('sess-defaults-custom')
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
})
|
||||
@@ -138,6 +178,11 @@ describe('PermissionService', () => {
|
||||
.rejects.toThrow(/reserved for the derived not-a-preset state/)
|
||||
})
|
||||
|
||||
it('requires an explicit default when composition defaults match no preset', async () => {
|
||||
await expect(mounted({ approvalDefault: 'never' }))
|
||||
.rejects.toThrow(/configure defaultPreset explicitly/)
|
||||
})
|
||||
|
||||
it('reads a schema-less approval stand-in as the ask default', async () => {
|
||||
const ctx = await mounted({ approvalDefault: undefined })
|
||||
const session = freshSession('sess-standin')
|
||||
@@ -146,3 +191,111 @@ describe('PermissionService', () => {
|
||||
expect(ctx.permission.current(session.events)).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('new-session default', () => {
|
||||
it('pins the current setting into each new session without changing earlier sessions', async () => {
|
||||
const ctx = await mountedStore()
|
||||
const first = ctx.sessions.create(SessionId('first'))
|
||||
expect(first.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'workspace-write' }],
|
||||
['sandbox/mode', { mode: 'workspace-write' }],
|
||||
['approval/policy', { policy: 'ask' }],
|
||||
])
|
||||
|
||||
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'danger-full-access',
|
||||
})
|
||||
expect(ctx.permission.defaultPreset).toBe('danger-full-access')
|
||||
const second = ctx.sessions.create(SessionId('second'))
|
||||
expect(ctx.permission.current(first.events)).toBe('workspace-write')
|
||||
expect(ctx.permission.current(second.events)).toBe('danger-full-access')
|
||||
expect(second.events.map(event => event.type)).toEqual([
|
||||
'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a seeded legacy session instead of applying the latest user default', async () => {
|
||||
const ctx = await mountedStore()
|
||||
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'danger-full-access',
|
||||
})
|
||||
const legacy = freshSession('legacy-source')
|
||||
legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
|
||||
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
|
||||
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
|
||||
'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves composition defaults when an empty stored session resumes', async () => {
|
||||
const ctx = await mountedStore()
|
||||
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'danger-full-access',
|
||||
})
|
||||
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
|
||||
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
|
||||
expect(resumed.events.map(event => event.type)).toEqual([
|
||||
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
})
|
||||
|
||||
it('pins sessions that already exist when the service remounts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: 'workspace-write',
|
||||
resolve() { throw new Error('permission tests do not execute bash') },
|
||||
run() { throw new Error('permission tests do not execute bash') },
|
||||
start() { throw new Error('permission tests do not execute bash') },
|
||||
})
|
||||
ctx.provide('approval', { config: { policy: 'ask' } })
|
||||
const existing = ctx.sessions.create(SessionId('existing-before-permission'))
|
||||
expect(existing.events).toEqual([])
|
||||
|
||||
await ctx.plugin(PermissionService, {})
|
||||
expect(existing.events.map(event => event.type)).toEqual([
|
||||
'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
expect(ctx.permission.current(existing.events)).toBe('workspace-write')
|
||||
})
|
||||
|
||||
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
|
||||
const ctx = await mountedStore()
|
||||
const partial = freshSession('partial-source')
|
||||
partial.append('sandbox/mode', { mode: 'workspace-write' })
|
||||
partial.append('approval/policy', { policy: 'ask' })
|
||||
const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events })
|
||||
expect(resumed.events.at(-1)).toMatchObject({
|
||||
type: 'permission/preset',
|
||||
data: { preset: 'workspace-write' },
|
||||
})
|
||||
|
||||
const custom = freshSession('custom-source')
|
||||
custom.append('sandbox/mode', { mode: 'read-only' })
|
||||
custom.append('approval/policy', { policy: 'never' })
|
||||
const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events })
|
||||
expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET)
|
||||
expect(unmatched.events.at(-1)?.type).toBe('session/end-seed')
|
||||
})
|
||||
|
||||
it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => {
|
||||
const ctx = await mountedStore({ approvalDefault: undefined })
|
||||
const partial = freshSession('approval-fallback-source')
|
||||
partial.append('sandbox/mode', { mode: 'workspace-write' })
|
||||
const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events })
|
||||
expect(resumed.events.at(-1)).toMatchObject({
|
||||
type: 'approval/policy',
|
||||
data: { policy: 'ask' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a stored default outside the configured preset table', async () => {
|
||||
const ctx = await mountedStore()
|
||||
await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'missing',
|
||||
})).rejects.toThrow()
|
||||
expect(ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise<Agent> {
|
||||
}
|
||||
|
||||
describe('permissions projection unit', () => {
|
||||
it('serves the composition-default select at zero events', async () => {
|
||||
it('serves the pinned new-session default select', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const value = ctx.sessionProjections.snapshot(session).values.permissions
|
||||
expect(value).toMatchObject({ currentValue: 'workspace-write' })
|
||||
@@ -103,12 +103,14 @@ describe('/permission command', () => {
|
||||
kind: 'success',
|
||||
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
|
||||
})
|
||||
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
|
||||
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects an unknown preset without touching the log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const agent = await agentFor(ctx, session)
|
||||
const before = session.events.filter(event =>
|
||||
event.type !== 'command/run' && event.type !== 'command/done')
|
||||
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
|
||||
// The error text carries the same no-self-labelling rule as the success
|
||||
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
|
||||
@@ -117,6 +119,7 @@ describe('/permission command', () => {
|
||||
kind: 'error',
|
||||
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
|
||||
})
|
||||
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
|
||||
expect(session.events.filter(event =>
|
||||
event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
}
|
||||
|
||||
@@ -52,15 +52,28 @@ function pretty(value: unknown): string {
|
||||
return displayText(serialized ?? String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* A side's content lines under the terminator rule the Web DiffBlock also
|
||||
* applies: empty text is zero lines (a full deletion's `newText`, a create's
|
||||
* absent `oldText`), and a single trailing newline terminates the last line
|
||||
* rather than adding an empty one. An interior blank line survives. Keeping the
|
||||
* two front ends on the same rule holds their `+A -R` footers in step.
|
||||
*/
|
||||
function diffContentLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
const body = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||
return body.split('\n')
|
||||
}
|
||||
|
||||
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
|
||||
function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
// The card header is a fixed `Tool / <name>` frame that never names a file, so
|
||||
// each hunk always carries its own path header (no redundancy to suppress).
|
||||
const lines = [palette.bold(displayText(diff.path))]
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`))
|
||||
for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`))
|
||||
}
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`))
|
||||
for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -389,12 +402,15 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
// A generic card's own content, or a web card's fallback to the raw result
|
||||
// content (the `web` view carries no `content` copy), both render as one dim
|
||||
// Markdown block below, so links/lists/headings keep the unified dim styling
|
||||
// rather than reading as bare text. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic'
|
||||
// A generic card's own content, or a read card's `content` fallback (the
|
||||
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
|
||||
// read renders exactly as before the read card existed), or a web card's
|
||||
// fallback to the raw result content (the `web` view carries no `content`
|
||||
// copy), all render as one dim Markdown block below, so links/lists/headings
|
||||
// keep the unified dim styling rather than reading as bare text. Terminal and
|
||||
// diff cards own their body styling, so they are excluded (mirrors
|
||||
// renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic' || view.card === 'read'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
@@ -501,25 +517,30 @@ export class ToolCardComponent implements Component {
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
// header. A trailing footer summarizes the change (`+A -R · N file(s)`).
|
||||
// header. A trailing footer summarizes the change (`+A -R · N file(s)`),
|
||||
// on the same terminator rule and distinct-path count the Web DiffBlock
|
||||
// uses, so the two front ends' footers agree.
|
||||
let added = 0
|
||||
let removed = 0
|
||||
const paths = new Set<string>()
|
||||
const hunks = view.diffs.flatMap((diff, index) => {
|
||||
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
|
||||
added += displayText(diff.newText).split('\n').length
|
||||
paths.add(diff.path)
|
||||
if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length
|
||||
added += diffContentLines(displayText(diff.newText)).length
|
||||
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
|
||||
})
|
||||
const files = view.diffs.length
|
||||
const files = paths.size
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
// The web card carries no `content` copy, so a `web` result view falls back
|
||||
// to the raw result content here (`view.card === 'generic'` narrows the
|
||||
// generic union arm; a `web` card takes the same fallback, mirroring the
|
||||
// `markdownContent` selection in render()).
|
||||
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
|
||||
// A generic or read card carries its own envelope-stripped `content`; a `web`
|
||||
// card carries no `content` copy and falls back to the raw result content
|
||||
// here. (Mirrors the `markdownContent` selection in render(); a read card has
|
||||
// no dedicated TUI rendering, so its `content` takes the same body path,
|
||||
// keeping read output as it was before the read card existed.)
|
||||
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
|
||||
@@ -4409,6 +4409,25 @@ describe('tool cards and surface replay', () => {
|
||||
diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }],
|
||||
}),
|
||||
},
|
||||
scatteredDiff: {
|
||||
name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
// Three hunks in ONE file. The first two sides end in the terminator
|
||||
// newline real write/edit content carries; the third removes a line and
|
||||
// leaves an EMPTY added side (a full deletion), so `diffContentLines('')`
|
||||
// returns zero lines. The footer must read `+2 -1 · 1 file`: each trailing
|
||||
// newline terminates its line rather than adding a phantom empty one, the
|
||||
// empty side contributes no `+ ` row, and the three hunks count as the
|
||||
// single distinct path they touch.
|
||||
presentCall: () => ({
|
||||
card: 'diff',
|
||||
title: 'Edit src/scatter.ts',
|
||||
diffs: [
|
||||
{ path: 'src/scatter.ts', oldText: null, newText: 'first\n' },
|
||||
{ path: 'src/scatter.ts', oldText: null, newText: 'second\n' },
|
||||
{ path: 'src/scatter.ts', oldText: 'gone\n', newText: '' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
generic: {
|
||||
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
|
||||
@@ -4735,6 +4754,35 @@ describe('tool cards and surface replay', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('counts a same-file diff once and terminates its trailing newline', async () => {
|
||||
// A budget past the card's row count so every hunk row stays visible (the
|
||||
// collapse arithmetic is covered elsewhere); this test is about the
|
||||
// terminator rule and the distinct-path footer count.
|
||||
const result = await setup({ tools, config: { maxToolOutputLines: 20 } })
|
||||
appendUser(result.session, 'scatter edits in one file')
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Editing' },
|
||||
{ type: 'tool-call', id: 'scatter' as never, name: 'scatteredDiff', arguments: '{}' },
|
||||
])
|
||||
result.session.append('tool/call', {
|
||||
turn: 1, step: 1, callId: 'scatter' as never, name: 'scatteredDiff', arguments: '{}',
|
||||
})
|
||||
await tick()
|
||||
const output = result.terminal.output
|
||||
// Three hunks, one path: distinct-path count, same as the Web DiffBlock.
|
||||
expect(output).toContain('· 1 file')
|
||||
expect(output).not.toContain('· 3 files')
|
||||
// The `first\n`/`second\n` sides each contribute exactly one added line —
|
||||
// the trailing newline terminates rather than adding a phantom empty `+ `.
|
||||
expect(output).toContain('+ first')
|
||||
expect(output).toContain('+ second')
|
||||
// The third hunk removes `gone` and leaves an empty added side, which
|
||||
// contributes no `+ ` row (diffContentLines('') is zero lines).
|
||||
expect(output).toContain('- gone')
|
||||
expect(output).toContain('+2 -1')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('drops blank rows from a terminal card result that the dim styling wraps', async () => {
|
||||
const blankRowTools: Record<string, ToolDefinition> = {
|
||||
trailing: {
|
||||
|
||||
Reference in New Issue
Block a user