refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
6
packages/interaction/user-approval/README.i18n.yaml
Normal file
6
packages/interaction/user-approval/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/interaction/user-approval/README.md
|
||||
README.md: 7b87a75d1c7c43874c484bc11f8deed45cb523ce
|
||||
README.zh.md: 3c02f8d673a62c3ee954da26f6341b433032a88c
|
||||
62
packages/interaction/user-approval/README.md
Normal file
62
packages/interaction/user-approval/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# @deepseek-ai/dsh-user-approval
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated [Cordis catalog](../../../docs/cordis-catalog/events.md).
|
||||
|
||||
Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision.
|
||||
|
||||
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns.
|
||||
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
|
||||
|
||||
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Current approval policy context
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
|
||||
|
||||
##### Ask-policy contribution
|
||||
|
||||
```markdown
|
||||
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
|
||||
```
|
||||
|
||||
##### Never-policy contribution
|
||||
|
||||
```markdown
|
||||
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only after retained history. An `ask`/`never` switch preserves the stable system and conversation prefix instead of rewriting the first wire message.
|
||||
|
||||
### Tool outcome
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`approval/asked` and `approval/decided` are log-only. The model sees only the asking consumer's eventual allowed, rejected, cancelled, or unavailable tool outcome; the human permission UI is not context.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero duplicate audit tokens. A rejection may replace a normal tool result with a small retained error, while an allowance leaves the consumer's ordinary result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Requests are valid only inside an open turn** — an idle or between-turn caller throws before auditing; a durable out-of-turn approval workflow is deferred.
|
||||
- **Only one-shot grants exist** — the outcome vocabulary has `allowed-once` but no `allow-always`, remembered rule, revocation, or grant store; session policy is only `ask` / `never`.
|
||||
- **The request carries no tool arguments** — an answerer sees the tool name, reason, and optional call id; the ACP machine channel requires a call id and delegates requests without one.
|
||||
- **No built-in answerer** — headless or incompletely composed deployments resolve `unavailable` and fail closed; the service itself never prompts a human.
|
||||
62
packages/interaction/user-approval/README.zh.md
Normal file
62
packages/interaction/user-approval/README.zh.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# @deepseek-ai/dsh-user-approval
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见生成的 [Cordis 目录](../../../docs/cordis-catalog/events.md)。
|
||||
|
||||
每个请求都必须属于一个尚未结束的 agent(智能体)轮次。服务会追加一对 `approval/asked` 与 `approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会解析为 `cancelled`;如果审计记录的追加在提交前失败,Promise 会被拒绝,而不会返回一项未记录的决定。
|
||||
|
||||
应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其负责的会话提供一次性机器决定。
|
||||
|
||||
`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。
|
||||
|
||||
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 当前审批策略上下文
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
首次请求和有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,批准贡献会说明可咨询已配置的应答者,缺少应答者时以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。
|
||||
|
||||
##### Ask 策略贡献
|
||||
|
||||
```markdown
|
||||
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
|
||||
```
|
||||
|
||||
##### Never 策略贡献
|
||||
|
||||
```markdown
|
||||
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在保留的历史之后仅追加。`ask`/`never` 切换会保留稳定的系统与对话前缀,而不会改写第一条 wire 消息。
|
||||
|
||||
### 工具结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`approval/asked` 和 `approval/decided` 只写入日志。模型只会看到发起请求的消费方最终给出的允许、拒绝、取消或不可用工具结果;面向人类的权限 UI 不属于上下文。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
不会产生重复的审计 token。拒绝可能以一条简短且会保留的错误信息替换正常工具结果,而允许会保留消费方的普通结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **请求只在尚未结束的轮次内有效**:在空闲时或轮次之间发起调用,会在审计前抛出异常;持久化的轮次外审批工作流仍属暂缓事项。
|
||||
- **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`、已记住的规则、撤销或授权存储;会话策略只有 `ask`/`never`。
|
||||
- **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 id;ACP 机器通道要求调用 id,并会委托不含 id 的请求。
|
||||
- **没有内置应答者**:无头或组合不完整的部署会 resolve 为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。
|
||||
55
packages/interaction/user-approval/package.json
Normal file
55
packages/interaction/user-approval/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-user-approval",
|
||||
"description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
348
packages/interaction/user-approval/src/index.ts
Normal file
348
packages/interaction/user-approval/src/index.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Approval request, cancellation, audit, and per-session policy seam. Missing
|
||||
* answerers fail closed; grants apply only to the requested action.
|
||||
* @module @deepseek-ai/dsh-user-approval
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
approval: ApprovalService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Ask composed answerers for one decision. Return an outcome to claim the
|
||||
* request or call `next()`; failure yields the fail-closed default.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* An approval question was put to the answerer chain — log-only audit
|
||||
* (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
|
||||
* it with the `approval/decided` that always follows; `toolName` is the
|
||||
* tool the question is about, `callId` the exact tool call when the asker
|
||||
* had one, `reason` the asker's human-readable explanation (e.g. a hook's
|
||||
* permission-decision reason).
|
||||
*/
|
||||
'approval/asked': {
|
||||
id: ApprovalRequestId
|
||||
toolName: string
|
||||
callId?: CallId
|
||||
reason?: string
|
||||
}
|
||||
/**
|
||||
* The outcome of a prior `approval/asked` (same `id`) — log-only audit.
|
||||
* Exactly one per ask, appended when the outcome is known: a decision, a
|
||||
* cancellation, or the fail-closed `'unavailable'`.
|
||||
*/
|
||||
'approval/decided': {
|
||||
id: ApprovalRequestId
|
||||
outcome: ApprovalOutcome
|
||||
}
|
||||
/**
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
* from the runtime-context snapshot and live switch notices). The LAST
|
||||
* such event is the session's override ({@link effectiveApprovalPolicy}).
|
||||
* `source: 'delegation'` marks an override seeded into a child; an absent
|
||||
* source is a runtime switch.
|
||||
*/
|
||||
'approval/policy': {
|
||||
policy: ApprovalPolicy
|
||||
/** Marks an override seeded into a child at delegation. */
|
||||
source?: 'delegation'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { ApprovalRequestId } from './types.ts'
|
||||
import type { ApprovalOutcome } from './types.ts'
|
||||
|
||||
export { ApprovalRequestId } from './types.ts'
|
||||
export type { ApprovalOutcome } from './types.ts'
|
||||
|
||||
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
|
||||
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
|
||||
|
||||
/**
|
||||
* A session's approval policy — what happens to an {@link ApprovalService}
|
||||
* ask BEFORE any interactive answerer sees it:
|
||||
*
|
||||
* - `'ask'` (the default) — delegate to the composed answerers; with none
|
||||
* composed the chain falls through to the fail-closed `'unavailable'`
|
||||
* (exactly today's behavior).
|
||||
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
|
||||
* deterministically. The strict headless stance (CI, unattended runs) and
|
||||
* the policy whose outcome is knowable without asking.
|
||||
*/
|
||||
export type ApprovalPolicy = 'ask' | 'never'
|
||||
|
||||
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
|
||||
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
|
||||
|
||||
/** Model-facing statement for the deterministic `'never'` policy. */
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
/** Model-facing statement for an interactive policy that may still fail closed. */
|
||||
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
|
||||
|
||||
/**
|
||||
* The session's approval-policy override: the last `approval/policy` event in
|
||||
* the log, or undefined when the session never switched (callers apply the
|
||||
* plugin's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the policy of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'approval/policy') return event.data.policy
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the log currently sits inside an open turn (a `turn/start` not yet
|
||||
* closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
|
||||
* The audit pair must be turn-enclosed: the turn is the durable log's
|
||||
* commit/replay boundary, so a bare event appended between turns is
|
||||
* indistinguishable from a crash tail and silently dropped on reload.
|
||||
*/
|
||||
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const type = (events[index] as SessionEvent).type
|
||||
if (type === 'turn/start') return true
|
||||
if (type === 'turn/end') return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the sole durable representation of a session policy override. Invalid
|
||||
* values throw before the log changes; consumers fold the new value on each read.
|
||||
* @param session - the session the override belongs to.
|
||||
* @param policy - the policy in effect until the next switch.
|
||||
*/
|
||||
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
|
||||
if (!APPROVAL_POLICIES.includes(policy)) {
|
||||
throw new TypeError('approval policy must be one of "ask" or "never"')
|
||||
}
|
||||
session.append('approval/policy', { policy })
|
||||
}
|
||||
|
||||
/**
|
||||
* Readonly same-process permission question. `callId` links to an already
|
||||
* presented tool call, so arguments are not duplicated here.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
/**
|
||||
* The agent on whose behalf the question is asked. Routes the question (a
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
* events on its session log.
|
||||
*/
|
||||
readonly agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
readonly toolName: string
|
||||
/**
|
||||
* The exact tool call being decided, when the asker has one — lets a UI
|
||||
* attach the prompt to the tool call it already streamed.
|
||||
*/
|
||||
readonly callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
readonly reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's default {@link ApprovalPolicy} for sessions without an
|
||||
* `approval/policy` override — `'ask'` delegates to the composed answerers
|
||||
* (fail-closed with none); `'never'` auto-rejects every ask without
|
||||
* prompting (the deterministic CI/unattended stance).
|
||||
*/
|
||||
readonly policy?: ApprovalPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval service that applies session policy before answerers and logs every
|
||||
* ask/outcome pair to the requesting session. It exposes deterministic policy
|
||||
* changes to the model through the runtime-context snapshot and switch notices.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
policy: z.union(['ask', 'never'] as const).default('ask'),
|
||||
})
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'approval')
|
||||
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
|
||||
|
||||
// The complete current value travels after retained history, so switching
|
||||
// policy does not rewrite the stable system-prompt cache prefix.
|
||||
ctx.inject(['systemPrompt'], (scope: Context) => {
|
||||
scope.systemPrompt.context({
|
||||
name: 'approval:policy',
|
||||
order: 115,
|
||||
text: (context) => {
|
||||
const agent = context.agent
|
||||
// A bare assemble() (tests, diagnostics) has no session to state.
|
||||
if (agent === undefined) return ''
|
||||
const policy = effective(agent)
|
||||
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch one live agent's policy and queue the transition for its next model
|
||||
* step. Session initialization uses {@link setApprovalPolicy} directly
|
||||
* because there is no previously visible policy to change.
|
||||
* @param agent - the live agent whose policy is changing.
|
||||
* @param policy - the new effective policy.
|
||||
*/
|
||||
setPolicy(agent: Agent, policy: ApprovalPolicy): void {
|
||||
const previous = this.effectivePolicy(agent.session)
|
||||
if (previous === policy) return
|
||||
setApprovalPolicy(agent.session, policy)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the composed answerers to decide one readonly same-process request.
|
||||
* The service borrows the request, agent, session, and live signal directly.
|
||||
* The request requires an open turn because the audit pair must be enclosed
|
||||
* by the durable log's commit/replay boundary; an idle ask rejects before
|
||||
* appending anything. The answerer phase always produces an outcome: an
|
||||
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields
|
||||
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
|
||||
* normalized to `'unavailable'`. A failure that prevents either audit append
|
||||
* from committing still rejects because returning an unlogged decision would
|
||||
* violate the pair. Session contains post-commit observer failures, so an
|
||||
* authoritative append cannot reject the request or suppress its matching
|
||||
* audit event.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @returns the closed outcome; `'allowed-once'` is the only grant.
|
||||
* @throws when no turn is open or either audit event fails before the session
|
||||
* append commit point.
|
||||
*/
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
const session = req.agent.session
|
||||
if (!hasOpenTurn(session.events)) {
|
||||
throw new Error(
|
||||
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
|
||||
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
|
||||
+ 'Ask from inside the turn that needs the decision.',
|
||||
)
|
||||
}
|
||||
const id = ApprovalRequestId(randomUUID())
|
||||
session.append('approval/asked', {
|
||||
id,
|
||||
toolName: req.toolName,
|
||||
...req.callId !== undefined ? { callId: req.callId } : {},
|
||||
...req.reason !== undefined ? { reason: req.reason } : {},
|
||||
})
|
||||
const outcome = await this.decide(req, session)
|
||||
session.append('approval/decided', { id, outcome })
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's effective policy: its own `approval/policy` fold, else the
|
||||
* configured default (the schema already defaulted an omitted policy to
|
||||
* `'ask'`; the `??` only narrows the optional-input TYPE).
|
||||
* @param session - the exact accepted session whose policy applies.
|
||||
* @returns the policy every ask for this session resolves under right now.
|
||||
*/
|
||||
private effectivePolicy(session: Session): ApprovalPolicy {
|
||||
return this.overrideOf(session) ?? this.config.policy ?? 'ask'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session override without applying the configured default.
|
||||
* @param session - session whose log supplies the override.
|
||||
* @returns the last logged policy, or `undefined` without one.
|
||||
*/
|
||||
overrideOf(session: Session): ApprovalPolicy | undefined {
|
||||
return effectiveApprovalPolicy(session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the waterfall, contained and raced against the request signal.
|
||||
* @param req - the borrowed public request.
|
||||
* @param session - the request agent's session used for policy lookup.
|
||||
* @returns the normalized closed outcome.
|
||||
*/
|
||||
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
|
||||
const signal = req.signal
|
||||
if (signal?.aborted) return 'cancelled'
|
||||
// The 'never' policy is decided HERE, before any dispatch: a listener
|
||||
// registered with `prepend: true` after this service mounts would sit
|
||||
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
|
||||
// documented promise that 'never' rejects deterministically regardless
|
||||
// of registration order — only the service's own request path can.
|
||||
if (this.effectivePolicy(session) === 'never') return 'rejected'
|
||||
// Enter the promise chain BEFORE dispatching: a listener that throws
|
||||
// SYNCHRONOUSLY (before its first await) must land in the same rejection
|
||||
// path as an async one — `Promise.resolve(call())` would let it escape
|
||||
// the containment into the caller.
|
||||
const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
|
||||
() => this.ctx.waterfall(
|
||||
scopeTarget(this, req.agent), 'approval/request', req,
|
||||
() => Promise.resolve<ApprovalOutcome>('unavailable'),
|
||||
),
|
||||
).then(
|
||||
// Normalize a rogue (non-vocabulary) answerer return to the fail-closed
|
||||
// outcome instead of leaking it into callers' closed-union switches.
|
||||
outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
|
||||
// A throwing answerer must fail the QUESTION closed, not the caller's
|
||||
// tool call open — the seam contains its callbacks.
|
||||
() => 'unavailable',
|
||||
)
|
||||
if (signal === undefined) return answer
|
||||
return await new Promise<ApprovalOutcome>((resolve) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve('cancelled')
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void answer.then((outcome) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
// After an abort won the race this resolve is a settled-promise no-op:
|
||||
// the late answer is discarded by construction.
|
||||
resolve(outcome)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ApprovalService
|
||||
111
packages/interaction/user-approval/src/invariant.ts
Normal file
111
packages/interaction/user-approval/src/invariant.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { ApprovalRequestId } from './index.ts'
|
||||
import { APPROVAL_POLICIES } from './index.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-user-approval'
|
||||
const APPROVAL_OUTCOMES = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] as const
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'user-approval-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
type ApprovalTransition =
|
||||
| { kind: 'asked'; id: ApprovalRequestId }
|
||||
| { kind: 'decided'; id: ApprovalRequestId }
|
||||
|
||||
interface ApprovalTrace {
|
||||
openTurn: number | null
|
||||
pending: Set<ApprovalRequestId>
|
||||
}
|
||||
|
||||
/** Validate one approval event against committed unmatched questions. */
|
||||
function validateApprovalEvent(
|
||||
trace: ApprovalTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): ApprovalTransition | undefined {
|
||||
if (event.type === 'approval/asked') {
|
||||
if (trace.openTurn === null) fail('approval/asked appended outside any open turn')
|
||||
if (event.data.toolName.length === 0) fail('approval/asked toolName must be non-empty')
|
||||
if (trace.pending.has(event.data.id)) fail(`approval/asked repeated open id ${JSON.stringify(event.data.id)}`)
|
||||
return { kind: 'asked', id: event.data.id }
|
||||
}
|
||||
if (event.type === 'approval/decided') {
|
||||
if (trace.openTurn === null) fail('approval/decided appended outside any open turn')
|
||||
if (!trace.pending.has(event.data.id)) fail(`approval/decided has no matching approval/asked for id ${JSON.stringify(event.data.id)}`)
|
||||
if (!APPROVAL_OUTCOMES.includes(event.data.outcome)) {
|
||||
fail(`approval/decided carries unknown outcome ${JSON.stringify(event.data.outcome)}`)
|
||||
}
|
||||
return { kind: 'decided', id: event.data.id }
|
||||
}
|
||||
if (event.type === 'approval/policy' && !APPROVAL_POLICIES.includes(event.data.policy)) {
|
||||
fail(`approval/policy carries unknown policy ${JSON.stringify(event.data.policy)}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Apply one accepted approval-pair transition. */
|
||||
function applyApprovalTransition(pending: Set<ApprovalRequestId>, transition: ApprovalTransition): void {
|
||||
if (transition.kind === 'asked') pending.add(transition.id)
|
||||
else pending.delete(transition.id)
|
||||
}
|
||||
|
||||
/** Install audit pairing and closed-vocabulary checks. */
|
||||
// Event owners keep precommit staging local so their vocabularies never move into a central helper.
|
||||
/* jscpd:ignore-start */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, ApprovalTrace>()
|
||||
const staged = new WeakMap<SessionEvent, { session: Session; transition: ApprovalTransition }>()
|
||||
const seed = (session: Session): ApprovalTrace => {
|
||||
const trace: ApprovalTrace = { openTurn: null, pending: new Set() }
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'turn/start') trace.openTurn = event.data.turn
|
||||
else if (event.type === 'turn/end') trace.openTurn = null
|
||||
const transition = validateApprovalEvent(trace, event, fail)
|
||||
if (transition !== undefined) applyApprovalTransition(trace.pending, transition)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
const traceFor = (session: Session): ApprovalTrace => traces.get(session) ?? seed(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seed(session)
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const trace = traceFor(session)
|
||||
if (event.type === 'turn/start') {
|
||||
trace.openTurn = event.data.turn
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
trace.openTurn = null
|
||||
return
|
||||
}
|
||||
if (event.type !== 'approval/asked' && event.type !== 'approval/decided') return
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next -- internal/dispatch stages every package-owned pair event */
|
||||
if (candidate === undefined || candidate.session !== session) return fail('approval audit event published without pre-commit validation')
|
||||
staged.delete(event)
|
||||
applyApprovalTransition(trace.pending, candidate.transition)
|
||||
}, { global: true })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const transition = validateApprovalEvent(traceFor(session), event, fail)
|
||||
if (transition !== undefined) staged.set(event, { session, transition })
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the approval invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
29
packages/interaction/user-approval/src/types.ts
Normal file
29
packages/interaction/user-approval/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Wire-safe approval identifiers and outcome vocabulary, free of
|
||||
* cordis/service imports so browser type chains (apiproxy api → client) can
|
||||
* consume them without loading this package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-user-approval/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Pairs one `approval/asked` audit event with its `approval/decided`.
|
||||
* Service-issued (one fresh id per {@link ApprovalService.request} call).
|
||||
*/
|
||||
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link ApprovalRequestId}.
|
||||
* @param id - the raw id string to brand.
|
||||
* @returns the same string carrying the brand.
|
||||
*/
|
||||
export function ApprovalRequestId(id: string): ApprovalRequestId {
|
||||
return id as ApprovalRequestId
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
|
||||
* request, or unavailable answerer. Callers fail closed on `unavailable`.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
514
packages/interaction/user-approval/tests/approval.spec.ts
Normal file
514
packages/interaction/user-approval/tests/approval.spec.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
/**
|
||||
* A minimal Agent stand-in — the service only reaches `agent.session.append`
|
||||
* and folds `.events`. Seeded inside an open turn by default (request()'s
|
||||
* turn-enclosure precondition); pass `seed` to stage idle/closed logs.
|
||||
* Returns the recorded audit appends alongside the fake.
|
||||
*/
|
||||
function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
|
||||
const appended: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
const agent = {
|
||||
session: {
|
||||
events: seed,
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
appended.push({ type, data })
|
||||
return { type, data } as unknown as SessionEvent
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
return { agent, appended }
|
||||
}
|
||||
|
||||
async function mounted(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
|
||||
return { agent, toolName: 'echo', ...overrides }
|
||||
}
|
||||
|
||||
describe('ApprovalService.request', () => {
|
||||
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([])
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
|
||||
expect(appended).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
|
||||
expect(appended).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed to unavailable when nobody listens, auditing the asked/decided pair', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
const outcome = await ctx.approval.request(requestOf(agent, { callId: CallId('call-1'), reason: 'hook says ask' }))
|
||||
|
||||
expect(outcome).toBe('unavailable')
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
const [asked, decided] = appended
|
||||
expect(asked?.data).toMatchObject({ toolName: 'echo', callId: 'call-1', reason: 'hook says ask' })
|
||||
expect(decided?.data).toMatchObject({ outcome: 'unavailable' })
|
||||
expect(decided?.data['id']).toBe(asked?.data['id'])
|
||||
})
|
||||
|
||||
it('omits absent optional fields from the asked audit event', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
|
||||
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
|
||||
})
|
||||
|
||||
it('borrows the exact readonly request for scoped dispatch and audit', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let scope!: Scope
|
||||
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scope = createScope(inner, agent)
|
||||
}, { inject: ['approval'] }))
|
||||
let received: ApprovalRequest | undefined
|
||||
let carrier: unknown
|
||||
scope.ctx.on('approval/request', function (req) {
|
||||
received = req
|
||||
carrier = carrierKeyOf(this)
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
const request = requestOf(agent, {
|
||||
toolName: 'scoped-tool',
|
||||
callId: CallId('scoped-call'),
|
||||
reason: 'scoped reason',
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
|
||||
expect(carrier).toBe(agent)
|
||||
expect(received).toBe(request)
|
||||
expect(appended).toHaveLength(2)
|
||||
expect(appended[0]?.data).toMatchObject({
|
||||
toolName: 'scoped-tool',
|
||||
callId: 'scoped-call',
|
||||
reason: 'scoped reason',
|
||||
})
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'allowed-once' })
|
||||
expect(appended[1]?.data['id']).toBe(appended[0]?.data['id'])
|
||||
await scopeFiber.dispose()
|
||||
})
|
||||
|
||||
it('contains an approval/asked observer throw after append and still completes the pair', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const session = ctx.sessions.create(SessionId('asked-observer-throw'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'approval/asked') throw new Error('observer failed after asked append')
|
||||
})
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
|
||||
const audit = session.events.filter(event => event.type.startsWith('approval/'))
|
||||
const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked')
|
||||
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
|
||||
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(decided?.data.id).toBe(asked?.data.id)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append'))
|
||||
})
|
||||
|
||||
it('contains an approval/decided observer throw after append and still resolves', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const session = ctx.sessions.create(SessionId('decided-observer-throw'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { session } as unknown as Agent
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'approval/decided') throw new Error('observer failed after decided append')
|
||||
})
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
|
||||
|
||||
const audit = session.events.filter(event => event.type.startsWith('approval/'))
|
||||
const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked')
|
||||
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
|
||||
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append'))
|
||||
})
|
||||
|
||||
it('propagates an append failure that prevented audit log growth', async () => {
|
||||
const ctx = await mounted()
|
||||
const failure = new Error('append failed before log growth')
|
||||
const agent = {
|
||||
session: {
|
||||
events: [{ type: 'turn/start' }],
|
||||
append: () => { throw failure },
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('returns the first answering listener outcome (single decision slot)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let secondRan = false
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
ctx.on('approval/request', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve<ApprovalOutcome>('rejected')
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a non-owning listener delegate via next() down to the fail-closed default', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
ctx.on('approval/request', (_req, next) => next())
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('dispatches to global and matching agent-scoped listeners, never a foreign scope', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent: agentA } = fakeAgent()
|
||||
const { agent: agentB } = fakeAgent()
|
||||
let scopeA!: Scope
|
||||
let scopeB!: Scope
|
||||
const scopesFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scopeA = createScope(inner, agentA)
|
||||
scopeB = createScope(inner, agentB)
|
||||
}, { inject: ['approval'] }))
|
||||
const heard: string[] = []
|
||||
ctx.on('approval/request', (req, next) => {
|
||||
heard.push(req.agent === agentA ? 'global:A' : 'global:B')
|
||||
return next()
|
||||
})
|
||||
scopeA.ctx.on('approval/request', (_req, next) => {
|
||||
heard.push('scoped:A')
|
||||
return next()
|
||||
})
|
||||
scopeB.ctx.on('approval/request', (_req, next) => {
|
||||
heard.push('scoped:B')
|
||||
return next()
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agentA))).resolves.toBe('unavailable')
|
||||
await expect(ctx.approval.request(requestOf(agentB))).resolves.toBe('unavailable')
|
||||
|
||||
expect(heard).toEqual(['global:A', 'scoped:A', 'global:B', 'scoped:B'])
|
||||
await scopesFiber.dispose()
|
||||
})
|
||||
|
||||
it('keys the scoped dispatch carrier to the exact request agent', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let scope!: Scope
|
||||
const scopeFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scope = createScope(inner, agent)
|
||||
}, { inject: ['approval'] }))
|
||||
let seenKey: object | undefined
|
||||
scope.ctx.on('approval/request', function (req, next) {
|
||||
seenKey = carrierKeyOf(this)
|
||||
expect(req.agent).toBe(agent)
|
||||
return next()
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
|
||||
expect(seenKey).toBe(agent)
|
||||
await scopeFiber.dispose()
|
||||
})
|
||||
|
||||
it('contains a throwing answerer as unavailable', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
ctx.on('approval/request', () => Promise.reject(new Error('transport died')))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'unavailable' })
|
||||
})
|
||||
|
||||
it('normalizes a rogue non-vocabulary answer to unavailable', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
// A JS answerer can return anything; the seam must not leak it into
|
||||
// callers' closed-union switches.
|
||||
ctx.on('approval/request', () => Promise.resolve('yolo' as ApprovalOutcome))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('settles cancelled immediately on an already-aborted signal without asking anyone', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let asked = false
|
||||
ctx.on('approval/request', () => {
|
||||
asked = true
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
|
||||
const outcome = await ctx.approval.request(requestOf(agent, { signal: AbortSignal.abort() }))
|
||||
|
||||
expect(outcome).toBe('cancelled')
|
||||
expect(asked).toBe(false)
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
|
||||
})
|
||||
|
||||
it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let settleLate: ((outcome: ApprovalOutcome) => void) | undefined
|
||||
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((resolve) => { settleLate = resolve }))
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
|
||||
controller.abort()
|
||||
await expect(pending).resolves.toBe('cancelled')
|
||||
|
||||
// The answerer settles after the fact: no second decided event appears.
|
||||
settleLate?.('allowed-once')
|
||||
await Promise.resolve()
|
||||
expect(appended.filter(e => e.type === 'approval/decided')).toHaveLength(1)
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
|
||||
})
|
||||
|
||||
it('discards a late REJECTION after abort without an unhandled rejection', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let rejectLate: ((error: Error) => void) | undefined
|
||||
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((_resolve, reject) => { rejectLate = reject }))
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
|
||||
controller.abort()
|
||||
await expect(pending).resolves.toBe('cancelled')
|
||||
|
||||
rejectLate?.(new Error('answered too late'))
|
||||
// Drain microtasks: the contained rejection must not escape the seam.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 0) })
|
||||
})
|
||||
|
||||
it('resolves the answer when the signal never aborts', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('issues a fresh id per request', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
|
||||
const ids = appended.filter(e => e.type === 'approval/asked').map(e => e.data['id'])
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(ids[0]).not.toBe(ids[1])
|
||||
})
|
||||
|
||||
it('drops a disposed plugin listener from the chain (HMR safety)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
const fiber = await ctx.plugin((inner: Context) => {
|
||||
inner.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
})
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
|
||||
await fiber.dispose()
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('approval policy (the approval/policy fold)', () => {
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
|
||||
|
||||
/**
|
||||
* An agent stand-in over a REAL Session — gate and context fold real events;
|
||||
* the opened turn satisfies request()'s enclosure precondition.
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session } {
|
||||
const session = Session.create(SessionId(id))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const agent = { id, session } as unknown as Agent
|
||||
return { agent, session }
|
||||
}
|
||||
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
const { session } = sessionAgent('sess-fold')
|
||||
expect(effectiveApprovalPolicy(session.events)).toBeUndefined()
|
||||
setApprovalPolicy(session, 'never')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
expect(effectiveApprovalPolicy(session.events)).toBe('ask')
|
||||
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
|
||||
})
|
||||
|
||||
it('rejects a policy outside the closed vocabulary before appending', () => {
|
||||
const append = vi.fn()
|
||||
const session = { append } as unknown as Session
|
||||
|
||||
expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
|
||||
.toThrow('approval policy must be one of "ask" or "never"')
|
||||
expect(append).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
|
||||
// Direct construction bypasses the plugin schema (the SystemPrompt-test
|
||||
// precedent for covering a defaulted Config field's type-narrowing ??).
|
||||
const ctx = new Context()
|
||||
const service = new ApprovalService(ctx, {})
|
||||
const { agent } = sessionAgent('sess-bare-config')
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await expect(service.request({ agent, toolName: 'echo' })).resolves.toBe('allowed-once')
|
||||
})
|
||||
|
||||
it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent } = sessionAgent('sess-syncthrow')
|
||||
ctx.on('approval/request', () => { throw new Error('sync bug') })
|
||||
await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('a never config rejects deterministically without consulting any answerer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const { agent, session } = sessionAgent('sess-gate-1')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
// The audit pair still lands on the session log.
|
||||
expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1)
|
||||
expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent } = sessionAgent('sess-gate-2')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => {
|
||||
// Cordis prepend unshifts ahead of every existing listener, including any gate LISTENER the
|
||||
// service could register — which is exactly why the 'never' decision lives inside request()
|
||||
// instead. This eager grant would bypass a listener-based gate and therefore must never run.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', () => { consulted(); return Promise.resolve<ApprovalOutcome>('allowed-once') }, { prepend: true })
|
||||
const { agent, appended } = fakeAgent()
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
})
|
||||
|
||||
it('a session override outranks the configured default, in both directions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const { agent, session } = sessionAgent('sess-gate-3')
|
||||
expect(ctx.approval.overrideOf(session)).toBeUndefined()
|
||||
setApprovalPolicy(session, 'ask')
|
||||
expect(ctx.approval.overrideOf(session)).toBe('ask')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('queues a live policy switch for the next model step', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-policy-notice')
|
||||
const inject = vi.fn<Agent['inject']>()
|
||||
const liveAgent = { ...agent, inject } as Agent
|
||||
|
||||
ctx.approval.setPolicy(liveAgent, 'never')
|
||||
ctx.approval.setPolicy(liveAgent, 'never')
|
||||
|
||||
expect(effectiveApprovalPolicy(session.events)).toBe('never')
|
||||
expect(inject).toHaveBeenCalledOnce()
|
||||
expect(inject.mock.calls[0]?.[0]).toMatchObject({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'user-approval' },
|
||||
})
|
||||
})
|
||||
|
||||
it('contributes the complete current ask or never policy as cache-safe context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const askAgent = sessionAgent('sess-sect-ask').agent
|
||||
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
|
||||
setApprovalPolicy(session, 'never')
|
||||
const contextFor = async (context: object) =>
|
||||
(await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text
|
||||
expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE)
|
||||
expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
|
||||
// A bare assemble (no agent) has no session to state.
|
||||
expect(await contextFor({})).toBe('')
|
||||
})
|
||||
|
||||
it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session } = sessionAgent('sess-context-switch')
|
||||
const contextFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text
|
||||
expect(await contextFor()).toBe(ASK_SENTENCE)
|
||||
expect(await contextFor()).toBe(ASK_SENTENCE)
|
||||
setApprovalPolicy(session, 'never')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
expect(await contextFor()).toBe(NEVER_SENTENCE)
|
||||
expect(await contextFor()).toBe(NEVER_SENTENCE)
|
||||
})
|
||||
|
||||
it('disposes the runtime-context contribution with the service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const fiber = await ctx.plugin(ApprovalService)
|
||||
const { agent } = sessionAgent('sess-hmr-service-live')
|
||||
const contextFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent })).contexts.find(context => context.name === 'approval:policy')
|
||||
expect(await contextFor()).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(await contextFor()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
106
packages/interaction/user-approval/tests/invariant.spec.ts
Normal file
106
packages/interaction/user-approval/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
|
||||
import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApprovalInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function startTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}
|
||||
|
||||
describe('approval invariants', () => {
|
||||
it('accepts paired audit events and closed policy values', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
const id = ApprovalRequestId('ask-1')
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
session.append('approval/decided', { id, outcome: 'allowed-once' })
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
})
|
||||
|
||||
it('rebuilds an unmatched question from an existing session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const id = ApprovalRequestId('ask-resume')
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(ApprovalInvariant)
|
||||
expect(() => session.append('approval/decided', { id, outcome: 'cancelled' })).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
|
||||
it('adopts a bare session first observed through publication', async () => {
|
||||
const ctx = await setup()
|
||||
const session = Session.create(SessionId('bare-approval-session'))
|
||||
const id = ApprovalRequestId('bare-ask')
|
||||
const asked = {
|
||||
type: 'approval/asked', seq: 0, time: 0, data: { id, toolName: 'bash' },
|
||||
} as const
|
||||
const decided = {
|
||||
type: 'approval/decided', seq: 1, time: 1, data: { id, outcome: 'rejected' as const },
|
||||
} as const
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1 },
|
||||
})
|
||||
ctx.emit('session/event', session, asked)
|
||||
ctx.emit('session/event', session, decided)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects audit events outside any open turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => session.append('approval/asked', {
|
||||
id: ApprovalRequestId('ask-1'), toolName: 'bash',
|
||||
})).toThrow(/outside any open turn/)
|
||||
expect(() => session.append('approval/decided', {
|
||||
id: ApprovalRequestId('ask-1'), outcome: 'rejected',
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects an unenclosed audit event when replaying an existing session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('approval/asked', {
|
||||
id: ApprovalRequestId('ask-replay'), toolName: 'bash',
|
||||
})
|
||||
await ctx.plugin(InvariantService)
|
||||
await expect(ctx.plugin(ApprovalInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('rejects malformed and unpaired audit events', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
startTurn(session)
|
||||
const id = ApprovalRequestId('ask-1')
|
||||
expect(() => session.append('approval/asked', { id, toolName: '' }))
|
||||
.toThrow(/toolName must be non-empty/)
|
||||
session.append('approval/asked', { id, toolName: 'bash' })
|
||||
expect(() => session.append('approval/asked', { id, toolName: 'bash' }))
|
||||
.toThrow(/repeated open id/)
|
||||
expect(() => session.append('approval/decided', {
|
||||
id: ApprovalRequestId('missing'), outcome: 'rejected',
|
||||
})).toThrow(/no matching approval\/asked/)
|
||||
expect(() => session.append('approval/decided', { id, outcome: 'maybe' as never }))
|
||||
.toThrow(/unknown outcome/)
|
||||
expect(() => session.append('approval/policy', { policy: 'always' as never }))
|
||||
.toThrow(/unknown policy/)
|
||||
})
|
||||
})
|
||||
42
packages/interaction/user-approval/tsconfig.json
Normal file
42
packages/interaction/user-approval/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
packages/interaction/user-approval/tsdown.config.ts
Normal file
30
packages/interaction/user-approval/tsdown.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Build index and the invariant companion as separate single-entry bundles.
|
||||
* Both entries import src/types.ts (the browser-safe subpath), so a
|
||||
* multi-entry build emits a shared chunk the package's exact `files`
|
||||
* whitelist omits; separate builds inline it.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user