Merge branch 'codex/disable-telemetry-default' into master
Resolved conflicts: - packages/client/ui-settings-general/README.md, README.zh.md: kept PR opt-in telemetry description (DSH_TELEMETRY_MODE) - scripts/snapshots/translation-prompt-v4: kept master's newer README structure snapshot - i18n.yaml pairing records: resolved per file state - pnpm-lock.yaml: regenerated - Modify/delete conflicts (scaffold/telemetry, sdk-follow-up-capabilities): kept master deletions
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/README.md
|
||||
README.md: 257ed4647c74ef45973d717278cdaa420968b3f6
|
||||
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
|
||||
README.md: 7a1fce6e361a47cf4ac6f02a76107e049411662e
|
||||
README.zh.md: 9ea5953299874e7f27b8a2fedb8c06790e83065a
|
||||
|
||||
@@ -14,6 +14,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable API |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable API |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable API |
|
||||
| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | Product — stable API |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable API |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API |
|
||||
| [`e2b/`](e2b/README.md) | E2B providers | POC |
|
||||
@@ -46,7 +47,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable API |
|
||||
| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API |
|
||||
|
||||
@@ -14,6 +14,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
| [`api/`](api/README.md) | Remote BFF 装配与 TypeRT RPC Gateway | 产品:稳定接口 |
|
||||
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定接口 |
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定接口 |
|
||||
| [`schedule/`](schedule/README.md) | 仅限会话内的定时后续轮次 | 产品:稳定接口 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定接口 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定接口 |
|
||||
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
|
||||
@@ -46,7 +47,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
|
||||
| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
|
||||
| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定接口 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -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/api/gateway/README.md
|
||||
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
|
||||
README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a
|
||||
README.md: 96f55eead6aec50e2f39f5bcefe71b51cc853298
|
||||
README.zh.md: 8985fd1f57833bc05f045e72135bb69a5a198d50
|
||||
|
||||
@@ -20,6 +20,8 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H
|
||||
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
|
||||
`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. `ctx.remote.$dispatch()` is the other half of that surface, and it is the carrier's: the Client half owning the Host frame sink hands each decoded frame over, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. A consumer subscribes and never calls it.
|
||||
|
||||
Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
|
||||
|
||||
## Model Experience
|
||||
@@ -37,3 +39,4 @@ No direct effect; invoked business Services own any model-visible result.
|
||||
- Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection.
|
||||
- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection.
|
||||
- Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key.
|
||||
- Forwarded events reach `$on` exactly as the Host emitted them: no payload projection or redaction, no Scope-bound subscription, and no replay after a reconnect.
|
||||
|
||||
@@ -20,6 +20,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。`ctx.remote.$dispatch()` 是该面的另一半,且属于载体:持有 Host 帧 sink 的 Client 半把每个解码后的帧交进来,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。消费方只订阅,绝不调用它。
|
||||
|
||||
生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
## 模型体验
|
||||
@@ -37,3 +39,4 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
- Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。
|
||||
- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。
|
||||
- lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。
|
||||
- 被转发的事件原样到达 `$on`:没有载荷投影或脱敏,不支持 Scope 化订阅,重连后也不重放。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
*/
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { Context, Events } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
TypeRTClientRemote,
|
||||
RemoteResult,
|
||||
TypeRTCodec,
|
||||
TypeRTDisposer,
|
||||
TypeRTRemoteContribution,
|
||||
TypeRTRemoteEvent,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
interface MountToken {
|
||||
@@ -71,14 +73,28 @@ export function apply(ctx: Context): void {
|
||||
new ClientRemoteService(ctx)
|
||||
}
|
||||
|
||||
/** One subscribed listener after `$on` erased its per-event argument list. */
|
||||
type RemoteEventListener = (...args: never[]) => void
|
||||
|
||||
/**
|
||||
* One subscription, identified by the registration rather than by its listener:
|
||||
* two fibers may subscribe the same function object to the same event, and each
|
||||
* disposer must retire only its own registration.
|
||||
*/
|
||||
interface RemoteEventSubscription {
|
||||
readonly listener: RemoteEventListener
|
||||
}
|
||||
|
||||
class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
private readonly ownerCtx: Context
|
||||
private readonly namespaces = new Map<string, RemoteNamespaceHandle>()
|
||||
private readonly subscriptions = new Map<string, RemoteEventSubscription[]>()
|
||||
private mutations = Promise.resolve()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'remote')
|
||||
this.ownerCtx = ctx
|
||||
ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions')
|
||||
}
|
||||
|
||||
async $mount(contribution: TypeRTRemoteContribution): ReturnType<TypeRTClientRemote['$mount']> {
|
||||
@@ -91,6 +107,64 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
return async () => { await owned() }
|
||||
}
|
||||
|
||||
$on<Event extends TypeRTRemoteEvent>(
|
||||
event: Event,
|
||||
listener: Events[Event],
|
||||
): ReturnType<TypeRTClientRemote['$on']> {
|
||||
// The table is keyed by the runtime event name, so the argument list this
|
||||
// signature pins per event cannot survive in it; `$deliver` restores it
|
||||
// from the frame the Host emitted for that same name.
|
||||
const subscription: RemoteEventSubscription = { listener }
|
||||
const owned = this.ctx.effect(() => {
|
||||
const listeners = this.listeners(event)
|
||||
listeners.push(subscription)
|
||||
return () => {
|
||||
const at = listeners.indexOf(subscription)
|
||||
/* v8 ignore next -- listener */
|
||||
if (at >= 0) listeners.splice(at, 1)
|
||||
}
|
||||
}, `api-gateway.client.$on(${JSON.stringify(event)})`)
|
||||
return () => { void owned() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one forwarded event in registration order, isolating a listener
|
||||
* that fails either synchronously or by rejecting a returned promise; see
|
||||
* {@link TypeRTClientRemote.$dispatch} for the caller contract.
|
||||
*/
|
||||
$dispatch(event: string, args: readonly unknown[]): void {
|
||||
const listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) return
|
||||
// Snapshot: a listener may subscribe or dispose during delivery, and this
|
||||
// round's recipients are the ones registered when the frame arrived.
|
||||
for (const { listener } of [...listeners]) {
|
||||
const report = (error: unknown): void => {
|
||||
console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error)
|
||||
}
|
||||
try {
|
||||
/* oxlint-disable-next-line typescript/no-confusing-void-expression --
|
||||
* The declared return is void, so nobody awaits an async listener; the
|
||||
* runtime value is still a promise, and reading it is the only way to
|
||||
* keep its rejection inside this containment instead of surfacing as an
|
||||
* unhandled one. */
|
||||
const settled: unknown = listener(...args as never[])
|
||||
if (settled instanceof Promise) settled.catch(report)
|
||||
} catch (error) {
|
||||
report(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */
|
||||
private listeners(event: string): RemoteEventSubscription[] {
|
||||
let listeners = this.subscriptions.get(event)
|
||||
if (listeners === undefined) {
|
||||
listeners = []
|
||||
this.subscriptions.set(event, listeners)
|
||||
}
|
||||
return listeners
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation, operation)
|
||||
this.mutations = result.then(() => undefined, () => undefined)
|
||||
@@ -255,7 +329,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
): Promise<unknown> {
|
||||
): Promise<RemoteResult<unknown>> {
|
||||
if (scoped !== undefined) {
|
||||
const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context)
|
||||
const identity = binder?.identity(callerCtx)
|
||||
@@ -286,9 +360,9 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
callerCtx: Context,
|
||||
values: readonly unknown[],
|
||||
boundIdentity?: BoundContextIdentity,
|
||||
): Promise<unknown> {
|
||||
): Promise<RemoteResult<unknown>> {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
if (!token.active) return withdrawn(endpoint)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
@@ -318,7 +392,8 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
let valueIndex = 0
|
||||
descriptor.parameters.forEach((parameter, parameterIndex) => {
|
||||
if (parameterIndex === projection?.parameterIndex) return
|
||||
args[parameter.wire] = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire)
|
||||
if (value !== undefined) args[parameter.wire] = value
|
||||
valueIndex += 1
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
@@ -327,10 +402,16 @@ class ClientRemoteService extends Service implements TypeRTClientRemote {
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
|
||||
if (!result.ok) throw remoteFailure(endpoint, result.error)
|
||||
return parse(descriptor.result, result.value, endpoint, 'result')
|
||||
try {
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) return withdrawn(endpoint)
|
||||
if (!result.ok) return { ok: false, error: result.error }
|
||||
return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') }
|
||||
} catch (error) {
|
||||
// Carrier throws (offline, abort, a rejected result payload) are outcomes
|
||||
// of the call, not assembly faults, so they join the same error branch.
|
||||
return carrierFailure(endpoint, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +420,7 @@ type InvokeRemote = (
|
||||
scoped: ScopedMethod | undefined,
|
||||
callerCtx: Context,
|
||||
args: readonly unknown[],
|
||||
) => Promise<unknown>
|
||||
) => Promise<RemoteResult<unknown>>
|
||||
|
||||
class RemoteNamespaceService extends Service {
|
||||
private readonly methods = new Map<string, RemoteMethodRecord>()
|
||||
@@ -394,7 +475,7 @@ class RemoteNamespaceService extends Service {
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<unknown> {
|
||||
get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise<RemoteResult<unknown>> {
|
||||
const callerCtx = this.ctx
|
||||
const current = this.methods.get(method)
|
||||
const direct = current?.direct
|
||||
@@ -493,6 +574,15 @@ function parse(codec: TypeRTCodec, value: unknown, endpoint: string, field: stri
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(endpoint: string, error: RpcError): Error {
|
||||
return new Error(`client api: ${endpoint} failed: ${error.code}: ${error.message}`, { cause: error })
|
||||
/** The namespace retired before or during the call, so no request outcome exists. */
|
||||
function withdrawn(endpoint: string): RemoteResult<never> {
|
||||
return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
}
|
||||
|
||||
function carrierFailure(endpoint: string, error: unknown): RemoteResult<never> {
|
||||
return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
function internalFailure(message: string): RemoteResult<never> {
|
||||
return { ok: false, error: { code: 'internal', message, details: {} } }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,18 @@ export class TypertGatewayError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Business invocation lost its carrier cancellation race. */
|
||||
class RemoteInvocationCancelled extends Error {
|
||||
/**
|
||||
* @param endpoint - canonical Remote endpoint.
|
||||
* @param cause - business rejection observed after carrier cancellation.
|
||||
*/
|
||||
constructor(endpoint: string, cause: unknown) {
|
||||
super(`Remote invocation "${endpoint}" was aborted`, { cause })
|
||||
this.name = 'RemoteInvocationCancelled'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve strict generated definitions or conservative SRC markers against
|
||||
* current Cordis Services and TypeRT providers.
|
||||
@@ -157,7 +169,17 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
)
|
||||
}
|
||||
|
||||
const result = await Reflect.apply(method, receiver, args) as unknown
|
||||
let result: unknown
|
||||
try {
|
||||
result = await Reflect.apply(method, receiver, args) as unknown
|
||||
} catch (error) {
|
||||
if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error)
|
||||
throw error
|
||||
}
|
||||
// A weak descriptor declares no return type, so nothing returned is a void
|
||||
// result and rides the wire as an absent value field. A strict descriptor
|
||||
// keeps its schema: there, undefined has to be a declared result.
|
||||
if (result === undefined && descriptor.result.mode !== 'strict') return result
|
||||
return decode(descriptor.result, result, 'result-invalid', endpoint, 'result')
|
||||
}
|
||||
|
||||
@@ -190,6 +212,9 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
// A void or explicitly absent business result carries no `value` field;
|
||||
// JSON has no `undefined`, and the envelope's optional slot is the one
|
||||
// representation of absence that both args and results already use.
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
return rpcFailure(error)
|
||||
@@ -384,6 +409,11 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
args: Readonly<Record<string, unknown>>,
|
||||
endpoint: string,
|
||||
): Promise<unknown> {
|
||||
// An absent field reached assertExactArguments' allowance, so this parameter
|
||||
// takes undefined; a present-but-undefined field is not JSON-safe input and
|
||||
// still fails decode. Lookup ids are never omissible, so absence here only
|
||||
// ever belongs to a json parameter.
|
||||
if (!Object.hasOwn(args, parameter.wire)) return undefined
|
||||
const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire)
|
||||
if (parameter.source === 'json') return value
|
||||
const key = parameter.lookup
|
||||
@@ -439,6 +469,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
|
||||
function rpcFailure(error: unknown): ConnectionRpcResult {
|
||||
if (error instanceof RemoteInvocationCancelled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: error.message, details: {} },
|
||||
}
|
||||
}
|
||||
if (error instanceof TypeRTLookupFailure) {
|
||||
return { ok: false, error: error.failure as ConnectionRpcError }
|
||||
}
|
||||
@@ -559,7 +595,15 @@ function assertExactArguments(
|
||||
if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire)
|
||||
const actual = Reflect.ownKeys(args)
|
||||
const extra = actual.filter(key => typeof key !== 'string' || !expected.has(key))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key))
|
||||
// A JSON field may be omitted when the strict descriptor declares absence,
|
||||
// and always under SRC: a weak descriptor reads parameter names from the
|
||||
// JavaScript signature and cannot see which are optional, so LIB is where an
|
||||
// omitted required argument is caught. Lookup ids are never omissible.
|
||||
const acceptsMissing = new Set(descriptor.parameters
|
||||
.filter(parameter => parameter.source === 'json'
|
||||
&& (parameter.acceptsUndefined === true || parameter.codec.mode === 'src-json'))
|
||||
.map(parameter => parameter.wire))
|
||||
const missing = [...expected].filter(key => !Object.hasOwn(args, key) && !acceptsMissing.has(key))
|
||||
if (extra.length === 0 && missing.length === 0) return
|
||||
const clauses: string[] = []
|
||||
if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`)
|
||||
@@ -575,7 +619,10 @@ function decode(
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
if (codec.mode === 'strict') {
|
||||
value = codec.schema.parse(value)
|
||||
if (value === undefined) return value
|
||||
}
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
|
||||
@@ -16,7 +16,8 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: Host calls re-read authoritative Cordis and TypeRT
|
||||
* state, while Client methods and descriptors mutate in one owned effect.
|
||||
* state, while Client methods, descriptors, and `$on` subscriptions mutate in
|
||||
* one owned effect.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -1,40 +1,68 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
RemoteResult,
|
||||
TypeRTClientRemote,
|
||||
TypeRTContext,
|
||||
TypeRTRemoteScopeApi,
|
||||
TypeRTRemoteNamespace,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import type { ClientRemote } from '../src/client/index.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only forwarded Host event.
|
||||
* @param namespace - marker payload recorded by listeners.
|
||||
*/
|
||||
'fixture/changed'(namespace: string): void
|
||||
/**
|
||||
* Test-only forwarded Host event nobody subscribes to.
|
||||
* @param count - marker payload never observed.
|
||||
*/
|
||||
'fixture/idle'(count: number): void
|
||||
/**
|
||||
* Test-only event the Host assembly does not forward.
|
||||
* @param flag - marker payload never delivered.
|
||||
*/
|
||||
'fixture/unselected'(flag: boolean): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {}
|
||||
|
||||
interface TypeRTContextMap {
|
||||
fixture: TypeRTContext<string>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (
|
||||
'probe/create': (
|
||||
agentId: string,
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
) => Promise<RemoteResult<{ readonly ref: string }>>
|
||||
'probe/maybe': (value: string | null | undefined) => Promise<RemoteResult<string | null | undefined>>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteScopeMap {
|
||||
'fixture:goals/create': (
|
||||
'fixture:probe/create': (
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
) => Promise<RemoteResult<{ readonly ref: string }>>
|
||||
'fixture:probe/rename': (
|
||||
request: { readonly objective: string },
|
||||
) => Promise<RemoteResult<{ readonly renamed: boolean }>>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteNamespaceMap {
|
||||
goals: TypeRTRemoteNamespace<'goals'>
|
||||
probe: TypeRTRemoteNamespace<'probe'>
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,6 +71,19 @@ type FixtureContext = Omit<Context, 'remote'> & {
|
||||
readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'>
|
||||
}
|
||||
|
||||
// Compile-time contract of `$on`: the key face is the forwarding selection and
|
||||
// the listener signature is the owning package's own Cordis declaration.
|
||||
function remoteEventContracts(remote: ClientRemote): void {
|
||||
remote.$on('fixture/changed', (namespace) => { void namespace })
|
||||
// @ts-expect-error -- declared in Events but outside the forwarding selection.
|
||||
remote.$on('fixture/unselected', () => {})
|
||||
// @ts-expect-error -- not declared in Events at all.
|
||||
remote.$on('fixture/absent', () => {})
|
||||
// @ts-expect-error -- the listener signature comes from the event declaration.
|
||||
remote.$on('fixture/changed', (count: number) => { void count })
|
||||
}
|
||||
void remoteEventContracts
|
||||
|
||||
const idSchema = z.string().min(1)
|
||||
const requestSchema = z.object({ objective: z.string().min(1) })
|
||||
const createResultSchema = z.object({ ref: z.string().min(1) })
|
||||
@@ -50,9 +91,9 @@ const renameResultSchema = z.object({ renamed: z.boolean() })
|
||||
|
||||
function directDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/create',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
id: '@fixture/probe#probe/create',
|
||||
service: 'probe',
|
||||
namespace: 'probe',
|
||||
method: 'create',
|
||||
invocation: { kind: 'direct' },
|
||||
scope: { context: 'fixture', wire: 'agentId' },
|
||||
@@ -75,9 +116,9 @@ function directDescriptor(): InvocationDescriptor {
|
||||
|
||||
function contextDescriptor(): InvocationDescriptor {
|
||||
return {
|
||||
id: '@fixture/goals#goals/rename',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
id: '@fixture/probe#probe/rename',
|
||||
service: 'probe',
|
||||
namespace: 'probe',
|
||||
method: 'rename',
|
||||
invocation: {
|
||||
kind: 'context',
|
||||
@@ -95,12 +136,39 @@ function contextDescriptor(): InvocationDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
function maybeDescriptor(): InvocationDescriptor {
|
||||
const schema = z.union([z.string(), z.null(), z.undefined()])
|
||||
return {
|
||||
id: '@fixture/probe#probe/maybe',
|
||||
service: 'probe',
|
||||
namespace: 'probe',
|
||||
method: 'maybe',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: 'value',
|
||||
source: 'json',
|
||||
acceptsUndefined: true,
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
|
||||
}],
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#MaybeValue', schema },
|
||||
}
|
||||
}
|
||||
|
||||
async function bench(call: ConnectionHandle['rpc']['call']): Promise<Context> {
|
||||
const { ctx } = await benchFiber(call)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function benchFiber(
|
||||
call: ConnectionHandle['rpc']['call'],
|
||||
): Promise<{ readonly ctx: Context; readonly client: Fiber }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TypertRegistry)
|
||||
ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle)
|
||||
await ctx.plugin({ inject, apply })
|
||||
return ctx
|
||||
const client = ctx.plugin({ inject, apply })
|
||||
await client
|
||||
return { ctx, client }
|
||||
}
|
||||
|
||||
describe('Client TypeRT API', () => {
|
||||
@@ -108,28 +176,29 @@ describe('Client TypeRT API', () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const businessGoals = { owner: 'host business service' }
|
||||
const disposeBusinessGoals = ctx.provide('goals', businessGoals)
|
||||
const businessProbe = { owner: 'host business service' }
|
||||
const disposeBusinessProbe = ctx.provide('probe', businessProbe)
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
const retained = ctx.remote.goals.create
|
||||
const retained = ctx.remote.probe.create
|
||||
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).resolves.toEqual({ ref: 'goal-1' })
|
||||
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
'probe/create',
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.remote.goals.create(
|
||||
await expect(ctx.remote.probe.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
)).resolves.toEqual({ ref: 'goal-1' })
|
||||
)).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
const combinedSignal = call.mock.calls.at(-1)?.[3]
|
||||
expect(combinedSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(combinedSignal).not.toBe(callerAbort.signal)
|
||||
@@ -137,18 +206,62 @@ describe('Client TypeRT API', () => {
|
||||
callerAbort.abort(cancellation)
|
||||
expect(combinedSignal?.aborted).toBe(true)
|
||||
expect(combinedSignal?.reason).toBe(cancellation)
|
||||
await expect(ctx.remote.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
await expect(ctx.remote.probe.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('rejected "result"')
|
||||
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'client api: probe/create failed: client api: probe/create rejected "result"',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('goals')).toBe(businessGoals)
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
expect(ctx.get('remote.probe')).toBeUndefined()
|
||||
expect(ctx.get('probe')).toBe(businessProbe)
|
||||
expect(ctx.typert.remotes.list()).toEqual([])
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).rejects.toThrow('no longer mounted')
|
||||
disposeBusinessGoals()
|
||||
await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'client api: Remote method probe/create is no longer mounted',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
disposeBusinessProbe()
|
||||
})
|
||||
|
||||
it('encodes declared undefined as an omitted argument and distinguishes it from null results', async () => {
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValueOnce({ ok: true, value: undefined })
|
||||
.mockResolvedValueOnce({ ok: true, value: null })
|
||||
const ctx = await bench(call)
|
||||
const dispose = await ctx.remote.$mount({
|
||||
package: '@fixture/maybe',
|
||||
descriptors: [maybeDescriptor()],
|
||||
})
|
||||
|
||||
await expect(ctx.remote.probe.maybe(undefined)).resolves.toStrictEqual({ ok: true, value: undefined })
|
||||
expect(call).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api',
|
||||
'probe/maybe',
|
||||
{ args: {} },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect(ctx.remote.probe.maybe(null)).resolves.toStrictEqual({ ok: true, value: null })
|
||||
expect(call).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api',
|
||||
'probe/maybe',
|
||||
{ args: { value: null } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('projects one direct lookup descriptor onto an Agent-scoped alias', async () => {
|
||||
@@ -160,24 +273,25 @@ describe('Client TypeRT API', () => {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }),
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.create({ objective: 'ship scoped' })).resolves.toEqual({ ref: 'goal-2' })
|
||||
await expect(agentCtx.remote.probe.create({ objective: 'ship scoped' }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-2' } })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/create',
|
||||
'probe/create',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'ship scoped' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'wrong scope' }))
|
||||
await expect((ctx as FixtureContext).remote.probe.create({ objective: 'wrong scope' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
|
||||
await assembly.dispose()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
expect(ctx.get('remote.probe')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the caller Context identity for scoped namespace methods', async () => {
|
||||
@@ -189,23 +303,24 @@ describe('Client TypeRT API', () => {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const assembly = ctx.plugin(Object.assign(
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/goals', descriptors: [contextDescriptor()] }),
|
||||
(scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }),
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await assembly
|
||||
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'land' })).resolves.toEqual({ renamed: true })
|
||||
await expect(agentCtx.remote.probe.rename({ objective: 'land' }))
|
||||
.resolves.toEqual({ ok: true, value: { renamed: true } })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
'probe/rename',
|
||||
{ args: { agentId: 'agent-2', request: { objective: 'land' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'land' }))
|
||||
await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'land' }))
|
||||
.rejects.toThrow('requires a "fixture" Context')
|
||||
|
||||
await assembly.dispose()
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('remote.probe')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects weak descriptors and namespace collisions before registration', async () => {
|
||||
@@ -237,32 +352,32 @@ describe('Client TypeRT API', () => {
|
||||
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-duplicates',
|
||||
descriptors: [direct, { ...direct, id: '@fixture/goals#goals/create-again' }],
|
||||
descriptors: [direct, { ...direct, id: '@fixture/probe#probe/create-again' }],
|
||||
})).rejects.toThrow('repeats direct method')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-duplicates',
|
||||
descriptors: [context, { ...context, id: '@fixture/goals#goals/rename-again' }],
|
||||
descriptors: [context, { ...context, id: '@fixture/probe#probe/rename-again' }],
|
||||
})).rejects.toThrow('repeats scoped method')
|
||||
|
||||
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-live', descriptors: [direct] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#goals/create' }],
|
||||
})).rejects.toThrow('direct method goals/create is already mounted')
|
||||
package: '@fixture/direct-conflict', descriptors: [{ ...direct, id: '@fixture/other#probe/create' }],
|
||||
})).rejects.toThrow('direct method probe/create is already mounted')
|
||||
await disposeDirect()
|
||||
|
||||
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-live', descriptors: [context] })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#goals/rename' }],
|
||||
})).rejects.toThrow('scoped method goals/rename is already mounted')
|
||||
package: '@fixture/scoped-conflict', descriptors: [{ ...context, id: '@fixture/other#probe/rename' }],
|
||||
})).rejects.toThrow('scoped method probe/rename is already mounted')
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-method-conflict',
|
||||
descriptors: [{ ...context, id: '@fixture/goals#goals/remove', method: 'remove' }],
|
||||
descriptors: [{ ...context, id: '@fixture/probe#probe/remove', method: 'remove' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
const scopedService = ctx.get('remote.goals') as unknown as object
|
||||
const scopedService = ctx.get('remote.probe') as unknown as object
|
||||
Object.defineProperty(scopedService, 'custom', { configurable: true, value: () => undefined })
|
||||
await expect(ctx.remote.$mount({
|
||||
package: '@fixture/service-own-property-conflict',
|
||||
descriptors: [{ ...direct, id: '@fixture/goals#goals/custom', method: 'custom' }],
|
||||
descriptors: [{ ...direct, id: '@fixture/probe#probe/custom', method: 'custom' }],
|
||||
})).rejects.toThrow('conflicts with its namespace service')
|
||||
Reflect.deleteProperty(scopedService, 'custom')
|
||||
await disposeScoped()
|
||||
@@ -278,10 +393,11 @@ describe('Client TypeRT API', () => {
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.remote.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
await expect(agentCtx.remote.probe.rename({ objective: 'remounted' }))
|
||||
.resolves.toEqual({ ok: true, value: { renamed: true } })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
'probe/rename',
|
||||
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
@@ -293,7 +409,7 @@ describe('Client TypeRT API', () => {
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
id: '@fixture/probe#probe/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const defineProperty = Object.defineProperty
|
||||
@@ -308,11 +424,11 @@ describe('Client TypeRT API', () => {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/retry-batch', descriptors: [first, second] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
expect((ctx.remote.goals as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
expect(ctx.remote.probe.create).toBeTypeOf('function')
|
||||
expect((ctx.remote.probe as unknown as Record<string, unknown>).archive).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
@@ -322,7 +438,7 @@ describe('Client TypeRT API', () => {
|
||||
package: '@fixture/context-anchor',
|
||||
descriptors: [contextDescriptor()],
|
||||
})
|
||||
const namespace = ctx.get('remote.goals') as unknown as {
|
||||
const namespace = ctx.get('remote.probe') as unknown as {
|
||||
installScoped: (...args: unknown[]) => void
|
||||
readonly create?: unknown
|
||||
}
|
||||
@@ -384,28 +500,28 @@ describe('Client TypeRT API', () => {
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = await ctx.remote.$mount({
|
||||
package: '@fixture/goals',
|
||||
package: '@fixture/probe',
|
||||
descriptors: [descriptor, contextDescriptor()],
|
||||
})
|
||||
const create = ctx.remote.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).remote.goals
|
||||
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const create = ctx.remote.probe.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const probe = (ctx as FixtureContext).remote.probe
|
||||
const rename = probe.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
|
||||
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
|
||||
.rejects.toThrow('got 4')
|
||||
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).remote.goals.create({ objective: 'ship' }))
|
||||
await expect(rename.call(probe)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('expected 2 business argument(s)')
|
||||
await expect((ctx as FixtureContext).remote.goals.rename({ objective: 'ship' }))
|
||||
await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json'
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec')
|
||||
;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'strict'
|
||||
|
||||
ctx.set('connection', undefined)
|
||||
await expect(ctx.remote.goals.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('no active Connection')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
@@ -419,23 +535,30 @@ describe('Client TypeRT API', () => {
|
||||
const { scope: _scope, ...first } = directDescriptor()
|
||||
const second: InvocationDescriptor = {
|
||||
...first,
|
||||
id: '@fixture/goals#goals/archive',
|
||||
id: '@fixture/probe#probe/archive',
|
||||
method: 'archive',
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [first, second] })
|
||||
const invocation = ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [first, second] })
|
||||
const invocation = ctx.remote.probe.create('agent-1', { objective: 'ship' })
|
||||
await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) })
|
||||
await dispose()
|
||||
resolveCall({ ok: true, value: { ref: 'goal-1' } })
|
||||
|
||||
await expect(invocation).rejects.toThrow('withdrawn during invocation')
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
await expect(invocation).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'client api: Remote method probe/create is no longer mounted',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails a method obtained from a withdrawn namespace getter', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
const namespace = ctx.get('remote.goals') as unknown as object
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
|
||||
const namespace = ctx.get('remote.probe') as unknown as object
|
||||
const getWithdrawn = Object.getOwnPropertyDescriptor(namespace, 'create')?.get?.bind(namespace)
|
||||
|
||||
await dispose()
|
||||
@@ -453,7 +576,7 @@ describe('Client TypeRT API', () => {
|
||||
const { scope: _scope, ...base } = directDescriptor()
|
||||
const descriptor: InvocationDescriptor = {
|
||||
...base,
|
||||
id: '@fixture/goals#goals/prototype',
|
||||
id: '@fixture/probe#probe/prototype',
|
||||
method: 'prototype',
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
@@ -464,8 +587,8 @@ describe('Client TypeRT API', () => {
|
||||
}
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/prototype', descriptors: [descriptor] })
|
||||
|
||||
const method = (ctx.remote.goals as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
await expect(method?.('wire-value')).resolves.toEqual({ ref: 'goal-1' })
|
||||
const method = (ctx.remote.probe as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).prototype
|
||||
await expect(method?.('wire-value')).resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
const payload = call.mock.calls[0]?.[2] as { readonly args: Record<string, unknown> }
|
||||
expect(Object.getPrototypeOf(payload.args)).toBeNull()
|
||||
expect(Object.hasOwn(payload.args, '__proto__')).toBe(true)
|
||||
@@ -481,15 +604,15 @@ describe('Client TypeRT API', () => {
|
||||
return defineProperty(target, key, attributes)
|
||||
})
|
||||
try {
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] }))
|
||||
await expect(ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }))
|
||||
.rejects.toThrow('fixture namespace startup failure')
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/goals-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/probe-retry', descriptors: [directDescriptor()] })
|
||||
expect(ctx.remote.probe.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
@@ -509,13 +632,13 @@ describe('Client TypeRT API', () => {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).goals).toBeUndefined()
|
||||
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({
|
||||
package: '@fixture/direct-method-retry',
|
||||
descriptors: [directDescriptor()],
|
||||
})
|
||||
expect(ctx.remote.goals.create).toBeTypeOf('function')
|
||||
expect(ctx.remote.probe.create).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
@@ -533,41 +656,174 @@ describe('Client TypeRT API', () => {
|
||||
spy.mockRestore()
|
||||
}
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('remote.probe')).toBeUndefined()
|
||||
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
|
||||
const retry = await ctx.remote.$mount({ package: '@fixture/scoped-retry', descriptors: [contextDescriptor()] })
|
||||
expect((ctx.get('remote.goals') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
expect((ctx.get('remote.probe') as unknown as Record<string, unknown>).rename).toBeTypeOf('function')
|
||||
await retry()
|
||||
})
|
||||
|
||||
it('unregisters an empty scoped namespace so another provider can claim its name', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const dispose = await ctx.remote.$mount({ package: '@fixture/scoped', descriptors: [contextDescriptor()] })
|
||||
expect(ctx.get('remote.goals')).toBeDefined()
|
||||
expect(ctx.get('remote.probe')).toBeDefined()
|
||||
|
||||
await dispose()
|
||||
|
||||
expect(ctx.get('remote.goals')).toBeUndefined()
|
||||
expect(ctx.get('remote.probe')).toBeUndefined()
|
||||
const replacement = { owner: 'replacement' }
|
||||
const disposeReplacement = ctx.reflect.provide('remote.goals', replacement)
|
||||
expect(ctx.get('remote.goals')).toBe(replacement)
|
||||
const disposeReplacement = ctx.reflect.provide('remote.probe', replacement)
|
||||
expect(ctx.get('remote.probe')).toBe(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('throws RPC failures with the structured error as its cause', async () => {
|
||||
it('delivers an RPC failure in the error branch with the Host error verbatim', async () => {
|
||||
const rpcError = { code: 'internal' as const, message: 'host failed', details: {} }
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({ ok: false, error: rpcError }))
|
||||
await ctx.remote.$mount({ package: '@fixture/goals', descriptors: [directDescriptor()] })
|
||||
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
|
||||
|
||||
let failure: unknown
|
||||
const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' })
|
||||
expect(outcome.ok).toBe(false)
|
||||
if (outcome.ok) throw new Error('expected the Client API invocation to report a failure')
|
||||
expect(outcome.error).toBe(rpcError)
|
||||
})
|
||||
|
||||
it('folds a transport throw into the error branch', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockRejectedValue(new Error('carrier offline')))
|
||||
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
|
||||
|
||||
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'client api: probe/create failed: carrier offline',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('folds a carrier throw that is not an Error into the error branch', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockRejectedValue('carrier exploded'))
|
||||
await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] })
|
||||
|
||||
await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'internal',
|
||||
message: 'client api: probe/create failed: carrier exploded',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('owns each $on subscription in the calling fiber', async () => {
|
||||
const { ctx, client } = await benchFiber(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
const subscriber = ctx.plugin(Object.assign(
|
||||
(scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) },
|
||||
{ inject: ['remote'] },
|
||||
))
|
||||
await subscriber
|
||||
|
||||
ctx.remote.$dispatch('fixture/changed', ['settings'])
|
||||
expect(seen).toEqual(['settings'])
|
||||
|
||||
await subscriber.dispose()
|
||||
ctx.remote.$dispatch('fixture/changed', ['after fiber disposal'])
|
||||
expect(seen).toEqual(['settings'])
|
||||
|
||||
await client.dispose()
|
||||
expect(ctx.get('remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('isolates a throwing listener from the rest of the same event', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const seen: string[] = []
|
||||
const disposeFirst = ctx.remote.$on('fixture/changed', () => {
|
||||
throw new Error('fixture listener failure')
|
||||
})
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
try {
|
||||
await ctx.remote.goals.create('agent-1', { objective: 'ship' })
|
||||
} catch (error) {
|
||||
failure = error
|
||||
ctx.remote.$dispatch('fixture/changed', ['credentials'])
|
||||
|
||||
expect(seen).toEqual(['credentials'])
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'client api: Remote event "fixture/changed" listener threw:',
|
||||
expect.any(Error),
|
||||
)
|
||||
disposeFirst()
|
||||
ctx.remote.$dispatch('fixture/changed', ['commands'])
|
||||
expect(seen).toEqual(['credentials', 'commands'])
|
||||
expect(consoleError).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
if (!(failure instanceof Error)) throw new Error('expected Client API invocation to fail')
|
||||
expect(failure.message).toContain('internal: host failed')
|
||||
expect(failure.cause).toBe(rpcError)
|
||||
})
|
||||
|
||||
it('contains an async listener whose promise rejects', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const seen: string[] = []
|
||||
// The declared return is void, so nobody awaits an async listener: the
|
||||
// rejection has to be contained here or it escapes as an unhandled one.
|
||||
ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
try {
|
||||
ctx.remote.$dispatch('fixture/changed', ['credentials'])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(seen).toEqual(['credentials'])
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'client api: Remote event "fixture/changed" listener threw:',
|
||||
expect.any(Error),
|
||||
)
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('retires only its own registration when one listener subscribes twice', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
// One function object, two registrations. A table keyed by listener identity
|
||||
// stores it once, so the first frame would reach it once instead of twice
|
||||
// and either disposer would silence both.
|
||||
const listener = (namespace: string): void => { seen.push(namespace) }
|
||||
const disposeFirst = ctx.remote.$on('fixture/changed', listener)
|
||||
ctx.remote.$on('fixture/changed', listener)
|
||||
|
||||
ctx.remote.$dispatch('fixture/changed', ['both'])
|
||||
expect(seen).toEqual(['both', 'both'])
|
||||
|
||||
// The surviving registration keeps receiving after its twin retires.
|
||||
disposeFirst()
|
||||
ctx.remote.$dispatch('fixture/changed', ['survivor'])
|
||||
expect(seen).toEqual(['both', 'both', 'survivor'])
|
||||
|
||||
// Disposing twice is inert: the record is already gone, so the second call
|
||||
// must not splice the surviving twin out from under its own owner.
|
||||
disposeFirst()
|
||||
ctx.remote.$dispatch('fixture/changed', ['still here'])
|
||||
expect(seen).toEqual(['both', 'both', 'survivor', 'still here'])
|
||||
})
|
||||
|
||||
it('separates the consumer verb from the carrier handoff', () => {
|
||||
expectTypeOf<ClientRemote>().toHaveProperty('$on')
|
||||
// The carrier owning the frame sink calls this; a consumer subscribes instead.
|
||||
expectTypeOf<ClientRemote>().toHaveProperty('$dispatch')
|
||||
})
|
||||
|
||||
it('drops a forwarded event nobody subscribes to', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const seen: string[] = []
|
||||
ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) })
|
||||
|
||||
ctx.remote.$dispatch('fixture/idle', [1])
|
||||
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,12 @@ class GoalService extends Service {
|
||||
return this.nextResult === undefined ? value : this.nextResult
|
||||
}
|
||||
|
||||
@Remote
|
||||
maybe(value: string | null | undefined): string | null | undefined {
|
||||
this.calls.push('maybe')
|
||||
return value
|
||||
}
|
||||
|
||||
@Remote
|
||||
fail(request: unknown): never {
|
||||
void request
|
||||
@@ -782,6 +788,19 @@ describe('TypertGatewayService', () => {
|
||||
}), 'input-invalid')
|
||||
})
|
||||
|
||||
it('admits an omitted SRC field and hands the Host method undefined', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
// A weak descriptor reads parameter names from the JavaScript signature and
|
||||
// cannot see which are optional, so an absent field is admitted; the case
|
||||
// above keeps an explicitly undefined field rejected.
|
||||
await expect(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'passthrough',
|
||||
args: {},
|
||||
})).resolves.toBeUndefined()
|
||||
expect(service.calls).toContain('passthrough')
|
||||
})
|
||||
|
||||
it('rejects cyclic SRC input and non-JSON SRC results', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const cyclic: { self?: unknown } = {}
|
||||
@@ -945,7 +964,7 @@ describe('TypertGatewayService', () => {
|
||||
expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' })
|
||||
|
||||
registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
registerStrict(ctx, [createDescriptor(), maybeDescriptor()])
|
||||
expect(connection.matches?.('goals/create')).toBe(true)
|
||||
expect(connection.matches?.('goals/passthrough')).toBe(true)
|
||||
expect(connection.matches?.('goals')).toBe(false)
|
||||
@@ -973,6 +992,15 @@ describe('TypertGatewayService', () => {
|
||||
if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded')
|
||||
expect(invalid.error.message).toMatch(/exactly one plain-object args field/)
|
||||
|
||||
await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
})
|
||||
await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: null,
|
||||
})
|
||||
|
||||
for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) {
|
||||
const result = await handler(endpoint, { args: {} }, signal)
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
@@ -987,11 +1015,33 @@ describe('TypertGatewayService', () => {
|
||||
}
|
||||
|
||||
service.businessError = 'non-error failure' as unknown as Error
|
||||
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
|
||||
await expect(handler(
|
||||
'goals/fail',
|
||||
{ args: { request: null } },
|
||||
new AbortController().signal,
|
||||
)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'non-error failure', details: {} },
|
||||
})
|
||||
|
||||
// A business rejection observed while the carrier signal is already aborted
|
||||
// is the caller's cancellation, not an internal gateway fault.
|
||||
const cancelledCall = new AbortController()
|
||||
cancelledCall.abort(new Error('client disconnected'))
|
||||
service.businessError = new Error('fixture business failure')
|
||||
await expect(handler(
|
||||
'goals/fail',
|
||||
{ args: { request: null } },
|
||||
cancelledCall.signal,
|
||||
)).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'cancelled',
|
||||
message: 'Remote invocation "goals/fail" was aborted',
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
|
||||
await gatewayFiber.dispose()
|
||||
expect(connection.handler).toBeUndefined()
|
||||
})
|
||||
@@ -1302,6 +1352,28 @@ function strictOnlyDescriptor(): InvocationDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
function maybeDescriptor(): InvocationDescriptor {
|
||||
const value = strictCodec(
|
||||
'@fixture/gateway#MaybeValue',
|
||||
z.union([z.string(), z.null(), z.undefined()]),
|
||||
)
|
||||
return {
|
||||
id: '@fixture/gateway#goals/maybe',
|
||||
service: 'goals',
|
||||
namespace: 'goals',
|
||||
method: 'maybe',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{
|
||||
name: 'value',
|
||||
wire: 'value',
|
||||
source: 'json',
|
||||
acceptsUndefined: true,
|
||||
codec: value,
|
||||
}],
|
||||
result: value,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectCode(
|
||||
promise: Promise<unknown>,
|
||||
code: TypertGatewayError['code'],
|
||||
22
packages/api/gateway/tsconfig.client.json
Normal file
22
packages/api/gateway/tsconfig.client.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
packages/api/gateway/tsconfig.host.json
Normal file
30
packages/api/gateway/tsconfig.host.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/invariant.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,27 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/api/remotes/README.md
|
||||
README.md: 567ece0fb58d4b9c0b022dd2ce4d8ee87caccc57
|
||||
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a
|
||||
README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6
|
||||
README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946
|
||||
|
||||
@@ -6,15 +6,23 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
|
||||
|
||||
`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation.
|
||||
|
||||
The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway.
|
||||
The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
|
||||
|
||||
This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract.
|
||||
|
||||
## Forwarded Host events
|
||||
|
||||
`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`; the type-only `src/types.ts` derives its selection face. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it.
|
||||
|
||||
The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-agent-presets`, `dsh-commands`, `dsh-credentials`, `dsh-llm`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way.
|
||||
|
||||
## Build boundary
|
||||
|
||||
An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations.
|
||||
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory.
|
||||
This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift.
|
||||
|
||||
That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional.
|
||||
|
||||
The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`.
|
||||
|
||||
|
||||
@@ -6,15 +6,24 @@
|
||||
|
||||
`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。
|
||||
|
||||
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
|
||||
当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
|
||||
|
||||
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。
|
||||
|
||||
## 转发的 Host 事件
|
||||
|
||||
`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。
|
||||
|
||||
监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets`、`dsh-commands`、`dsh-credentials`、`dsh-llm`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。
|
||||
|
||||
## 构建边界
|
||||
|
||||
仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。
|
||||
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。
|
||||
本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts` 与 `src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。
|
||||
|
||||
这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。
|
||||
|
||||
|
||||
包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
@@ -26,6 +26,10 @@
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -47,26 +51,43 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
|
||||
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
|
||||
export type {} from '@deepseek-ai/dsh-commands/remote'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
|
||||
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
// The forwarded-event allowlist's selection seat: without it in the consumer's
|
||||
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
|
||||
export type { ApiRemoteForwardedEvent } from '../types.ts'
|
||||
// The owner packages' client-safe `./types` exports supply the `Events`
|
||||
// signatures `$on` hands to a listener, so a consumer reads the very
|
||||
// declaration the Host emits rather than a flattened restatement of it.
|
||||
export type {} from '@deepseek-ai/dsh-commands/types'
|
||||
export type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
export type {} from '@deepseek-ai/dsh-llm/types'
|
||||
export type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
export type {} from '@deepseek-ai/dsh-settings/types'
|
||||
|
||||
/**
|
||||
* The carrier's Client-facing types, re-exported so a business package names one
|
||||
* assembly package instead of both this facade and the Connection plugin. Type-only:
|
||||
* the carrier's runtime values stay behind their own module edge.
|
||||
*/
|
||||
export type {
|
||||
ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
|
||||
CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient,
|
||||
MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
|
||||
MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem,
|
||||
SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
|
||||
SubagentAddress, SubagentCatalog, TaskView, ToolCallView, ToolEventView, ToolResultView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -23,5 +57,16 @@ export const inject = ['remote']
|
||||
* @returns disposer after every selected Remote namespace is ready.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await ctx.remote.$mount(goalsRemote)
|
||||
const disposers: Array<() => Promise<void>> = []
|
||||
try {
|
||||
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote, messageFeedbackRemote]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
}
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
/** Host BFF entry and Loader shell for the Remote contribution assembly. */
|
||||
|
||||
import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta'
|
||||
import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
|
||||
|
||||
// The owner packages' client-safe `./types` exports carry the cordis `Events`
|
||||
// declarations for every allowlisted event. Pulling them into this face is what
|
||||
// makes the shape assertion below judge real signatures rather than an empty
|
||||
// event vocabulary.
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
import type {} from '@deepseek-ai/dsh-llm/types'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
|
||||
export {
|
||||
ApiRemoteSessionNotFound,
|
||||
ApiRemoteSubagentSessionOwnership,
|
||||
@@ -13,6 +26,18 @@ export type {
|
||||
ApiRemoteAgentResult,
|
||||
ApiRemoteLookupError,
|
||||
} from './agent-lookup.ts'
|
||||
export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
|
||||
export type { ApiRemoteForwardedEvent } from './types.ts'
|
||||
|
||||
// Shape gate over the allowlist, kept in the Host face because the Host's event
|
||||
// vocabulary is the authoritative one. It pins three things at compile time:
|
||||
// every entry NAMES a declared event (the predicate is keyed on `keyof
|
||||
// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not
|
||||
// `unknown`, which is how "must not depend on AgentScope" is stated statically),
|
||||
// and every entry is ONE-WAY (a waterfall or bail shape returns something other
|
||||
// than void and is excluded). Widening the array to an event that fails any of
|
||||
// these fails here, not on the wire.
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[]
|
||||
|
||||
/** Host plugin body; the selected contributions mount only in Client environments. */
|
||||
export function apply(): void {}
|
||||
|
||||
23
packages/api/remotes/src/remote-events.ts
Normal file
23
packages/api/remotes/src/remote-events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The one home of this application's forwarded-Host-event allowlist. Both
|
||||
* compiler faces list this file, so the Host forwarding loop and the consumer
|
||||
* `ctx.remote.$on` key face read one declaration instead of two copies that
|
||||
* could drift; `./types.ts` derives the type projection from it and stays
|
||||
* type-only.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host events this application forwards to consumers verbatim: no projection,
|
||||
* no redaction, no renaming. The wire name is the Host cordis event name and
|
||||
* the payload is its argument list, so this array is simultaneously the whole
|
||||
* control point over what a consumer can receive and the legal key set of
|
||||
* `ctx.remote.$on`. Forwarding one more event is an entry here and nothing
|
||||
* else.
|
||||
*/
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/updated',
|
||||
'llm/adapters-updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
19
packages/api/remotes/src/types.ts
Normal file
19
packages/api/remotes/src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Type face of the forwarded-Host-event allowlist: the consumer key projection
|
||||
* and the selection seat it fills. The allowlist VALUE lives in
|
||||
* `./remote-events.ts`, keeping this module type-only per the package
|
||||
* convention; both compiler faces list both files, so the Host forwarding loop
|
||||
* and the consumer `ctx.remote.$on` key face read one declaration instead of
|
||||
* two copies that could drift.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-remotes/types
|
||||
*/
|
||||
|
||||
import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
|
||||
|
||||
/** Type projection of the allowlist; the consumer and the Host read this one. */
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
|
||||
declare module '@deepseek-ai/dsh-type-meta' {
|
||||
interface TypeRTRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
@@ -147,19 +147,21 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
} catch {
|
||||
invalidRejected = true
|
||||
}
|
||||
// Every generated method resolves to the RemoteResult envelope; the
|
||||
// business values below are what the assertions pin.
|
||||
const rootResult = await client.remote.goals.create(rootAgent.id, { objective: 'root goal' })
|
||||
const rootEdit = await client.remote.goals.edit(
|
||||
rootAgent.id,
|
||||
rootResult.ref,
|
||||
rootResult.value.ref,
|
||||
{ objective: 'edited root goal' },
|
||||
)
|
||||
const agentContext = client.extend({ builtAgentId: scopedAgent.id })
|
||||
const scopedResult = await agentContext.remote.goals.create({ objective: 'scoped goal', maxGoalRounds: 3 })
|
||||
const result = {
|
||||
invalidRejected,
|
||||
rootResult,
|
||||
rootEdit,
|
||||
scopedResult,
|
||||
rootResult: rootResult.value,
|
||||
rootEdit: rootEdit.value,
|
||||
scopedResult: scopedResult.value,
|
||||
rootGoal: host.goals.get(rootAgent)?.objective,
|
||||
scopedGoal: host.goals.get(scopedAgent)?.objective,
|
||||
rootEvents: rootAgent.session.events.length,
|
||||
|
||||
@@ -6,15 +6,44 @@
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/client/index.ts"
|
||||
"src/client/index.ts",
|
||||
"src/remote-events.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../gateway/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../feedback/message-feedback"
|
||||
},
|
||||
{
|
||||
"path": "../../host/plugin-inventory"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"files": [
|
||||
"src/agent-lookup.ts",
|
||||
"src/index.ts",
|
||||
"src/invariant.ts"
|
||||
"src/invariant.ts",
|
||||
"src/remote-events.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
@@ -20,9 +22,24 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -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/attachment/attachment-local/README.md
|
||||
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
|
||||
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa
|
||||
README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
|
||||
README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
|
||||
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -14,9 +14,9 @@ export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
||||
/** Default maximum encoded bytes for one image. */
|
||||
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
/** Default maximum images in one prompt. */
|
||||
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
|
||||
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20
|
||||
/** Default maximum aggregate image bytes in one prompt. */
|
||||
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024
|
||||
/** Default maximum intrinsic pixels for one image. */
|
||||
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
|
||||
|
||||
@@ -68,8 +68,8 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
return saveImageFile(this.root, input, this.imageLimits)
|
||||
}
|
||||
|
||||
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref)
|
||||
async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,22 +197,32 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
* Read and verify one content-addressed image.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param ref - reference recorded in the session log.
|
||||
* @param signal - optional cancellation for filesystem and verification work.
|
||||
* @returns verified bytes and reference.
|
||||
* @throws the signal reason when aborted, or an AttachmentError when verification fails.
|
||||
*/
|
||||
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
export async function readImageFile(
|
||||
root: string,
|
||||
ref: ImageAttachmentRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
const sha256 = ensureReference(ref)
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256)))
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
// The digest proves these are the exact bytes admission fully decoded, so
|
||||
// the read path only re-derives the header fields (no raster decode, no
|
||||
// per-request pixel amplification on history replay).
|
||||
const metadata = await probeImage(data)
|
||||
signal?.throwIfAborted()
|
||||
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|
||||
|| metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
|
||||
@@ -14,6 +14,7 @@ import LocalAttachmentStore, {
|
||||
describe('local attachment service', () => {
|
||||
it('resolves every omitted admission limit explicitly', () => {
|
||||
const service = new LocalAttachmentStore(new Context(), {})
|
||||
expect(DEFAULT_MAX_IMAGE_BYTES).toBe(5 * 1024 * 1024)
|
||||
expect(service.imageLimits).toEqual({
|
||||
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
|
||||
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||
|
||||
@@ -9,12 +9,23 @@ import sharp from 'sharp'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import { readImageFile, saveImageFile } from '../src/store.ts'
|
||||
|
||||
const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
|
||||
const fsControl = vi.hoisted(() => ({
|
||||
readSignals: [] as AbortSignal[],
|
||||
syncedDirectories: [] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
readFile(...args: Parameters<typeof actual.readFile>): ReturnType<typeof actual.readFile> {
|
||||
const options = args[1]
|
||||
if (typeof options === 'object' && options !== null) {
|
||||
const signal = (options as { signal?: AbortSignal }).signal
|
||||
if (signal !== undefined) fsControl.readSignals.push(signal)
|
||||
}
|
||||
return actual.readFile(...args)
|
||||
},
|
||||
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
|
||||
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
|
||||
return actual.open(...args)
|
||||
@@ -130,6 +141,20 @@ describe('local attachment store', () => {
|
||||
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
|
||||
})
|
||||
|
||||
it('forwards read cancellation to the filesystem and preserves its reason', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
const controller = new AbortController()
|
||||
fsControl.readSignals.length = 0
|
||||
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG })
|
||||
expect(fsControl.readSignals).toEqual([controller.signal])
|
||||
|
||||
const cancellation = new Error('attachment read cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
|
||||
const storageRoot = await root()
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
|
||||
@@ -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/attachment/attachment/README.md
|
||||
README.md: 4f450316294e554396adb9a8454051a08d9befd3
|
||||
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890
|
||||
README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
|
||||
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -52,9 +52,11 @@ export abstract class AttachmentStore extends Service {
|
||||
/**
|
||||
* Read one image and verify that bytes still match the recorded reference.
|
||||
* @param ref - durable reference from the session log.
|
||||
* @param signal - optional cancellation for backend read and verification work.
|
||||
* @returns the verified bytes and canonical reference.
|
||||
* @throws the signal reason when aborted, or a storage error when verification fails.
|
||||
*/
|
||||
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
}
|
||||
|
||||
export default AttachmentStore
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-env",
|
||||
"description": "Tool-independent managed DSH_* shell environment registry",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -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/bash/bash-local/README.md
|
||||
README.md: 253d1c9efb518ca204956f062b2f529f4e83531b
|
||||
README.zh.md: 5a0d943a08d1da6c73ad07f469c056180b476ff3
|
||||
README.md: 386ffc00466108ab14352b6d39d3a20da3321e1c
|
||||
README.zh.md: 39d37bededa72ec96911bb1ce055ed105fdd7082
|
||||
|
||||
@@ -23,6 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
## Behavior
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
## 行为
|
||||
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-local",
|
||||
"description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,26 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a resolved section this executor could not run with. The schema
|
||||
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
|
||||
* fit, so a stored value is refused where it is written instead of failing at
|
||||
* the next command.
|
||||
* @param config - the resolved section, schema-valid by construction.
|
||||
* @throws Error naming the field that cannot be used.
|
||||
*/
|
||||
export function assertServiceableBashConfig(config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', resolved.graceMs)
|
||||
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local bash executor over `ctx.subprocess`. Bounded output, spill files, and
|
||||
* process-group SIGTERM→SIGKILL escalation are the subprocess service's
|
||||
@@ -90,21 +111,29 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
graceMs: z.number().default(DEFAULT_GRACE_MS),
|
||||
})
|
||||
|
||||
/** The currently authoritative config: the settings section, or the composition entry. */
|
||||
private source: () => ResolvedConfig
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
get config(): ResolvedConfig {
|
||||
return this.source()
|
||||
}
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
const entry = config as ResolvedConfig
|
||||
assertServiceableBashConfig(entry)
|
||||
this.source = () => entry
|
||||
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, {
|
||||
validate: assertServiceableBashConfig,
|
||||
setSource: (current) => {
|
||||
this.source = current as () => ResolvedConfig
|
||||
},
|
||||
// Every field is read through the getter at each command, so nothing
|
||||
// derived from the source needs rebuilding when the document changes.
|
||||
onChange: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
115
packages/bash/bash-local/tests/settings.spec.ts
Normal file
115
packages/bash/bash-local/tests/settings.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/** The `bash` settings section layered over the executor's composition entry. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return 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 = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}): Promise<{
|
||||
ctx: Context
|
||||
settingsFiber: Fiber
|
||||
executorFiber: Fiber
|
||||
bash: LocalBashExecutor
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const settingsFiber = ctx.plugin(MemorySettings)
|
||||
await settingsFiber.await()
|
||||
const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config })
|
||||
await executorFiber.await()
|
||||
return { ctx, settingsFiber, executorFiber, bash: ctx.bash as LocalBashExecutor }
|
||||
}
|
||||
|
||||
describe('bash settings section', () => {
|
||||
it('resolves the user layer over the composition entry', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.bash.config.timeoutMs).toBe(60_000)
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
|
||||
expect(bench.bash.config.timeoutMs).toBe(5_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a stored value the constructor would have rejected', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
|
||||
.rejects.toThrow(/positive finite/)
|
||||
|
||||
expect(bench.bash.config.timeoutMs).toBe(60_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a grace period longer than a timer can carry', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER }))
|
||||
.rejects.toThrow(/graceMs must be no greater than/)
|
||||
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves the stored section to every later read', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' })
|
||||
|
||||
const spec = bench.bash.resolve({ command: 'true' })
|
||||
|
||||
expect(spec.stdoutMaxBytes).toBe(1_024)
|
||||
expect(spec.workdir).toBe('/tmp')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the settings provider detaches', async () => {
|
||||
const bench = await boot()
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
expect(bench.bash.config.timeoutMs).toBe(5_000)
|
||||
|
||||
await bench.settingsFiber.dispose()
|
||||
|
||||
expect(bench.bash.config.timeoutMs).toBe(60_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the composition entry when no settings provider is mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 })
|
||||
|
||||
expect((ctx.bash as LocalBashExecutor).config.timeoutMs).toBe(1_234)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('releases the namespace when the executor unloads', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
|
||||
|
||||
await bench.executorFiber.dispose()
|
||||
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -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/bash/bash/README.md
|
||||
README.md: a076c6ef3150de86c251f0501b18de01c4491c67
|
||||
README.zh.md: 1be9f817a8379b2974fb4cfd63c197c320934f00
|
||||
README.md: ef873dfb87cb330847274be59d5f2a0c3a5bc0b9
|
||||
README.zh.md: 4047765f248a25ff700ba62bf87636c0e8bfe7ac
|
||||
|
||||
@@ -27,6 +27,8 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../
|
||||
|
||||
Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit.
|
||||
|
||||
`BASH_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.bash` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。
|
||||
|
||||
`BASH_SETTINGS_NAMESPACE`(`bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.bash` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。
|
||||
|
||||
## 词汇
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash",
|
||||
"description": "Abstract bash executor seam (ctx.bash) for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
@@ -35,12 +35,14 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,21 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
/**
|
||||
* Settings namespace of this capability, owned here rather than by either
|
||||
* executor family because it names the capability, not an implementation: a
|
||||
* host composes exactly one provider of `ctx.bash` (the win32 layer swaps the
|
||||
* POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate
|
||||
* service registration), so the providers share one namespace without ever
|
||||
* registering it twice, and a settings document carried between platforms
|
||||
* keeps resolving on both.
|
||||
*/
|
||||
export const BASH_SETTINGS_NAMESPACE = settingsNamespace('bash')
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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/bash/pwsh-local/README.md
|
||||
README.md: eb3365b009e3595230e5fb0f616079bd73c55840
|
||||
README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b
|
||||
README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e
|
||||
README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22
|
||||
|
||||
@@ -28,8 +28,9 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its `
|
||||
The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call:
|
||||
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output.
|
||||
- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section.
|
||||
- **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction.
|
||||
- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent.
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins.
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义:
|
||||
|
||||
- **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。
|
||||
- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。
|
||||
- **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。
|
||||
- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。
|
||||
- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。
|
||||
- **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-local",
|
||||
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
@@ -36,7 +36,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by
|
||||
design (see this package's README), so the two import the same seam surface */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
/* jscpd:ignore-end */
|
||||
import { resolvePwshPath } from './resolve.ts'
|
||||
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
|
||||
@@ -96,6 +100,26 @@ function assertPositiveFinite(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a resolved section this executor could not run with. The schema
|
||||
* expresses neither "positive and finite" nor the timer bound `graceMs` has to
|
||||
* fit, so a stored value is refused where it is written instead of failing at
|
||||
* the next command.
|
||||
* @param config - the resolved section, schema-valid by construction.
|
||||
* @throws Error naming the field that cannot be used.
|
||||
*/
|
||||
export function assertServiceablePwshConfig(config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', resolved.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', resolved.graceMs)
|
||||
if (resolved.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local PowerShell executor over `ctx.subprocess`. Bounded output, spill
|
||||
* files, and process-tree termination are the subprocess service's mechanics;
|
||||
@@ -114,25 +138,47 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
pwshPath: z.string(),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
/** The currently authoritative config: the settings section, or the composition entry. */
|
||||
private source: () => ResolvedConfig
|
||||
|
||||
/** The pwsh executable resolved once at construction. */
|
||||
readonly pwshPath: string
|
||||
/** The declared executable the current {@link pwshPath} was resolved from. */
|
||||
private declaredPwshPath: string | undefined
|
||||
|
||||
/** The pwsh executable resolved from the current config. */
|
||||
private resolvedPwshPath: string
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
get config(): ResolvedConfig {
|
||||
return this.source()
|
||||
}
|
||||
|
||||
/** The pwsh executable every command runs through. */
|
||||
get pwshPath(): string {
|
||||
return this.resolvedPwshPath
|
||||
}
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// Schemastery fills these fields before construction; the type does not encode that step.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes)
|
||||
assertPositiveFinite('graceMs', this.config.graceMs)
|
||||
if (this.config.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
this.pwshPath = resolvePwshPath(this.config.pwshPath)
|
||||
const entry = config as ResolvedConfig
|
||||
assertServiceablePwshConfig(entry)
|
||||
this.source = () => entry
|
||||
this.declaredPwshPath = entry.pwshPath
|
||||
this.resolvedPwshPath = resolvePwshPath(entry.pwshPath)
|
||||
installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, {
|
||||
validate: assertServiceablePwshConfig,
|
||||
setSource: (current) => {
|
||||
this.source = current as () => ResolvedConfig
|
||||
},
|
||||
// Probing the filesystem is the one fact derived from the source: every
|
||||
// other field is read through the getter at each command.
|
||||
onChange: () => {
|
||||
const declared = this.source().pwshPath
|
||||
if (declared === this.declaredPwshPath) return
|
||||
this.declaredPwshPath = declared
|
||||
this.resolvedPwshPath = resolvePwshPath(declared)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,10 +31,10 @@ const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInte
|
||||
/** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
|
||||
const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
|
||||
/** Filesystem path equality across macOS temp symlinks and Windows drive-letter casing. */
|
||||
function samePath(actual: string, expected: string): boolean {
|
||||
const norm = (value: string) => (
|
||||
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value
|
||||
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : realpathSync.native(value)
|
||||
)
|
||||
return norm(actual) === norm(expected)
|
||||
}
|
||||
|
||||
108
packages/bash/pwsh-local/tests/settings.spec.ts
Normal file
108
packages/bash/pwsh-local/tests/settings.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** The shared `bash` settings section as the pwsh executor family resolves it. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Fiber } from '@deepseek-ai/cordis'
|
||||
import { Settings } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return 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 = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(config: ConstructorParameters<typeof PwshLocalExecutor>[1] = {}): Promise<{
|
||||
ctx: Context
|
||||
settingsFiber: Fiber
|
||||
executorFiber: Fiber
|
||||
pwsh: PwshLocalExecutor
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const settingsFiber = ctx.plugin(MemorySettings)
|
||||
await settingsFiber.await()
|
||||
const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config })
|
||||
await executorFiber.await()
|
||||
return { ctx, settingsFiber, executorFiber, pwsh: ctx.bash as PwshLocalExecutor }
|
||||
}
|
||||
|
||||
describe('pwsh executor over the bash settings section', () => {
|
||||
it('resolves the user layer over the composition entry', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a stored value the constructor would have rejected', async () => {
|
||||
const bench = await boot()
|
||||
|
||||
await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 }))
|
||||
.rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/)
|
||||
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('re-resolves the executable when the stored path changes', async () => {
|
||||
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' })
|
||||
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps the resolved executable when an unrelated field changes', async () => {
|
||||
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
|
||||
const before = bench.pwsh.pwshPath
|
||||
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 })
|
||||
|
||||
expect(bench.pwsh.pwshPath).toBe(before)
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the settings provider detaches', async () => {
|
||||
const bench = await boot({ pwshPath: '/opt/first/pwsh' })
|
||||
await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' })
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(5_000)
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh')
|
||||
|
||||
await bench.settingsFiber.dispose()
|
||||
|
||||
expect(bench.pwsh.config.timeoutMs).toBe(60_000)
|
||||
expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('releases the namespace when the executor unloads', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash')
|
||||
|
||||
await bench.executorFiber.dispose()
|
||||
|
||||
expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash')
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-bash",
|
||||
"description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -174,11 +174,24 @@ describe('bash tool through the agent loop', () => {
|
||||
expect(resultText(toolResult)).toContain('[exit code: 9]')
|
||||
})
|
||||
|
||||
it('background: start ack → pending completion notice → task_output collects it', async () => {
|
||||
it('background: start ack → completion wakes the idle agent → task_output collects it', async () => {
|
||||
// The command blocks on a sentinel this test creates only after the agent
|
||||
// has gone idle, so settlement cannot fold into the still-running turn.
|
||||
// Without that fence a fast command can settle before step 2's pre-step
|
||||
// claim, which folds the notice into a turn whose scripted reply is final:
|
||||
// the turn then closes with an empty next-step inbox and the collection
|
||||
// entries are never reached.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-bg-'))
|
||||
dirs.push(dir)
|
||||
const sentinel = join(dir, 'release')
|
||||
// The task id is deterministic (a fresh LocalTaskService counts per kind from 1),
|
||||
// so the script can name `bash-1` without threading a generated id.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
||||
toolCallResponse('call-1', 'bash', {
|
||||
command: `while [ ! -f ${JSON.stringify(sentinel)} ]; do sleep 0.02; done; echo bg-ok`,
|
||||
description: 'test command',
|
||||
run_in_background: true,
|
||||
}),
|
||||
textResponse('Started it in the background.'),
|
||||
toolCallResponse('call-2', 'task_output', { task_id: 'bash-1' }),
|
||||
textResponse('Background task finished.'),
|
||||
@@ -192,29 +205,35 @@ describe('bash tool through the agent loop', () => {
|
||||
const firstResult = findEvent(events(agent), 'tool/result')
|
||||
expect(firstResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(firstResult)).toBe('started background task bash-1')
|
||||
|
||||
// The task settles on its own; the tool-tasks notice listener injects a
|
||||
// pending next-step message without waking the idle agent.
|
||||
// The turn closed with the task still running, so the notice cannot exist yet.
|
||||
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin'))
|
||||
const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')!
|
||||
expect(pendingNotice.content.some(
|
||||
block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'),
|
||||
)).toBe(true)
|
||||
expect(pendingNotice.source).toEqual({
|
||||
expect(events(agent).some(isNotice)).toBe(false)
|
||||
|
||||
// Releasing the command now settles it against a provably idle owner. No
|
||||
// second user message: the wake alone opens the turn that collects it.
|
||||
writeFileSync(sentinel, '')
|
||||
const lastResultText = (): string => {
|
||||
const found = events(agent).findLast(event => event.type === 'tool/result')
|
||||
return found === undefined ? '' : resultText(found)
|
||||
}
|
||||
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('bg-ok'))
|
||||
// Two turns: the user's, then the one the completion opened by itself.
|
||||
expect(events(agent).filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
// The notice carries the gated command as its label, so this pins the id,
|
||||
// the terminal status, and the producer identity; the verbatim notice text
|
||||
// and its bounding are pinned in the tool-tasks unit tests.
|
||||
const notice = events(agent).find(isNotice)!
|
||||
const noticeText = notice.data.content
|
||||
.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
expect(noticeText).toContain('background task bash-1 (bash: ')
|
||||
expect(noticeText).toContain('finished [status: completed, exit code: 0]')
|
||||
expect(notice.data.source).toMatchObject({
|
||||
kind: 'plugin',
|
||||
plugin: 'tool-tasks',
|
||||
form: 'notice',
|
||||
summary: 'bash echo bg-ok [status: completed, exit code: 0]',
|
||||
})
|
||||
|
||||
// The next turn first admits that notice as user/message, then collects
|
||||
// the output through the generic task tool.
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
const notice = events(agent).find(isNotice)!
|
||||
expect(notice.data).toEqual(pendingNotice)
|
||||
const readResult = findEvent(events(agent), 'tool/result', 'last')
|
||||
expect(readResult.data.message.content[0].isError).toBe(false)
|
||||
expect(resultText(readResult)).toContain('bg-ok')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-pwsh",
|
||||
"description": "Model-facing pwsh tool over the bash executor seam",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -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/boot/README.md
|
||||
README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14
|
||||
README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e
|
||||
README.md: cdf551729567a7ad4be9dbd99861db4ad57cd5d7
|
||||
README.zh.md: f775e9aac291ce176516c20eca773d6520050e23
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
|
||||
The channel-neutral boot library shared by `apps/cli` and the [`examples/`](../examples/README.md) demo bins.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
|
||||
由 `apps/cli` 和 [`examples/`](../examples/README.md) demo bin 共享、与渠道无关的启动库。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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/boot/app-boot/README.md
|
||||
README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700
|
||||
README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a
|
||||
README.md: 4c82aa749edbeada0a344b0b119d1644544d9732
|
||||
README.zh.md: 38298e091af4aa1b09c31dfa8141ce68fa1d3f50
|
||||
|
||||
@@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home:
|
||||
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
|
||||
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
|
||||
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
|
||||
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
每次 profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -106,6 +106,7 @@ const BOOTSTRAP_NAMES = new Set([
|
||||
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
|
||||
'EDITOR', 'VISUAL', 'PAGER',
|
||||
// Network reach and trust.
|
||||
'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL',
|
||||
'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
||||
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
|
||||
|
||||
@@ -110,21 +110,33 @@ function entryConfig(ctx: Context, id: string): unknown {
|
||||
}
|
||||
|
||||
describe('Loader config interpolation', () => {
|
||||
it("resolves Include's own !!js options", async () => {
|
||||
it("keeps Include's config literal — a nested row's !!js belongs to that row's fiber", async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
writeFileSync(join(dir, 'reader.mjs'), [
|
||||
'export const name = "reader"',
|
||||
'export function apply(ctx, config) { ctx.provide("observedValue", config.value) }',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: reader\n name: ./reader.mjs\n')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
|
||||
ctx.provide('answer', 42)
|
||||
try {
|
||||
// The include is a tree carrier: its own config (path, patches) stays
|
||||
// literal, and the expression nested inside the patched row's config
|
||||
// resolves against the row's fiber, not the include's.
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: { __jsExpr: "ctx.get('includePath')" } },
|
||||
config: {
|
||||
path: pathToFileURL(join(dir, 'cordis.yml')).href,
|
||||
patches: [{ id: 'reader', name: './reader.mjs', config: { value: { __jsExpr: "ctx.get('answer')" } } }],
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
|
||||
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
|
||||
expect(reader?.options.config).toEqual({ value: { __jsExpr: "ctx.get('answer')" } })
|
||||
expect(ctx.get('observedValue')).toBe(42)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -187,6 +199,74 @@ describe('Loader config interpolation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loader entry disabled interpolation', () => {
|
||||
it('evaluates a !!js disabled expression against the loader context', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: expr-off',
|
||||
' name: ./noop.mjs',
|
||||
' disabled: !!js process.version.length > 0',
|
||||
'- id: expr-on',
|
||||
' name: ./noop.mjs',
|
||||
' disabled: !!js process.version.length === 0',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const off = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-off')
|
||||
const on = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-on')
|
||||
expect(off?.disabled).toBe(true)
|
||||
expect(off?.fiber).toBeUndefined()
|
||||
expect(on?.disabled).toBe(false)
|
||||
expect(on?.fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the raw expression in the options so write-back preserves the !!js form', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.platform === "win32"\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
|
||||
// The evaluated boolean drives the mount decision; the serialized
|
||||
// expression node stays in the options for the file-backed tree.
|
||||
expect(entry?.options.disabled).toEqual({ __jsExpr: 'process.platform === "win32"' })
|
||||
expect(entry?.disabled).toBe(process.platform === 'win32')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-evaluates when update() replaces the expression, mounting and unmounting', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.version.length === 0\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr')
|
||||
expect(entry?.disabled).toBe(false)
|
||||
expect(entry?.fiber).toBeDefined()
|
||||
// The expression form is the file dialect; the typed programmatic API
|
||||
// carries booleans. Include reapplication feeds the raw node through
|
||||
// the untyped file path — simulated here with the serialized shape.
|
||||
const disabledTrue = { __jsExpr: 'process.version.length > 0' } as unknown as boolean
|
||||
const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
|
||||
await entry?.update({ disabled: disabledTrue })
|
||||
expect(entry?.disabled).toBe(true)
|
||||
expect(entry?.fiber).toBeUndefined()
|
||||
await entry?.update({ disabled: disabledFalse })
|
||||
expect(entry?.disabled).toBe(false)
|
||||
expect(entry?.fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with user patches', () => {
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
|
||||
@@ -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/boot/cmdline/README.md
|
||||
README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
|
||||
README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114
|
||||
README.md: 33125014539e801dbd2952a3b4513cafc80bdcee
|
||||
README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559
|
||||
|
||||
@@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones
|
||||
|
||||
## Ordinary providers and injected config
|
||||
|
||||
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service:
|
||||
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide('webStartup', values)
|
||||
const program = webCommand()
|
||||
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -45,14 +46,12 @@ Every row configured from those values uses ordinary service injection and direc
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
```
|
||||
|
||||
`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate.
|
||||
`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate.
|
||||
|
||||
### How injection orders config
|
||||
|
||||
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
|
||||
|
||||
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
|
||||
|
||||
### Shared immutable arguments
|
||||
|
||||
`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.
|
||||
|
||||
@@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
|
||||
|
||||
## 普通提供方与注入配置
|
||||
|
||||
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有:
|
||||
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有:
|
||||
|
||||
```ts ignore
|
||||
export const name = 'web-startup'
|
||||
export const inject = ['cmdlineArgs']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, webCommand(), planWebStartup)
|
||||
if (values !== undefined) ctx.provide('webStartup', values)
|
||||
const program = webCommand()
|
||||
program.action(() => ctx.provide('webStartup', webValuesFrom(program)))
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -45,14 +46,12 @@ export function apply(ctx: Context): void {
|
||||
port: !!js ctx.webStartup.port ?? 3080
|
||||
```
|
||||
|
||||
`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。
|
||||
`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。
|
||||
|
||||
### 注入如何排列配置求值
|
||||
|
||||
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
|
||||
|
||||
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
|
||||
|
||||
### 共享不可变参数
|
||||
|
||||
`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* text, and its parse errors instead of the launcher knowing them.
|
||||
*
|
||||
* Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
|
||||
* provider may publish the parsed values as its own service, and ordinary rows
|
||||
* provider may publish the parsed values as its own service from its program's
|
||||
* commander action, and ordinary rows
|
||||
* can inject that service and read it from lazily resolved config —
|
||||
* `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
|
||||
* beside it. No row has launcher-level command-line status.
|
||||
@@ -17,8 +18,6 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
// Empty type import carries the Loader Context merge used by enableRow.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
/**
|
||||
* The invocation's inner arguments: everything after the launcher's own flags,
|
||||
@@ -78,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve parsed arguments into an app-owned value. Call
|
||||
* `program.error(...)` to reject the invocation with a usage message instead
|
||||
* of throwing.
|
||||
* @param program - the parsed commander program.
|
||||
* @param ctx - the plugin context that received the command line.
|
||||
* @returns the value an ordinary provider plugin may publish.
|
||||
*/
|
||||
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
|
||||
|
||||
/**
|
||||
* Parse the launcher's immutable argument snapshot with an app's commander
|
||||
* program. The caller decides whether and how to publish the returned value;
|
||||
* this helper has no Loader-row or service ownership semantics.
|
||||
* program. Commander runs the program's own synchronous action handler on a
|
||||
* successful parse; app code there publishes its service and rejects an
|
||||
* invalid invocation with `program.error(...)`. This helper has no Loader-row
|
||||
* or service ownership semantics.
|
||||
*
|
||||
* Help, version, and rejected arguments are terminal for the process: commander
|
||||
* writes the text, the helper requests `ctx.appExit`, and it returns
|
||||
* `undefined` so the caller publishes nothing.
|
||||
* Help, version, and rejected arguments — from the grammar or from an action
|
||||
* — are terminal for the process: commander writes the text and the helper
|
||||
* requests `ctx.appExit`. The action never runs on help, version, or a
|
||||
* grammar rejection; an action must reject before it publishes, because
|
||||
* statements before its `program.error(...)` have already run.
|
||||
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
|
||||
* @param program - the app's commander program, with its flags and description already declared.
|
||||
* @param plan - this invocation's resolved value; omitted returns an empty object.
|
||||
* @returns the resolved value, or `undefined` when the app asked to exit.
|
||||
* @throws when the launcher did not provide the command line and exit request.
|
||||
* @param program - the app's commander program, with its flags, description,
|
||||
* actions, and any subcommands already declared.
|
||||
* @throws when the launcher did not provide the command line and exit request,
|
||||
* or when no command in the program declares an action.
|
||||
*/
|
||||
export function parseCmdline<T>(
|
||||
ctx: Context,
|
||||
program: Command,
|
||||
plan: CmdlinePlan<T> = (() => ({}) as T),
|
||||
): T | undefined {
|
||||
export function parseCmdline(ctx: Context, program: Command): void {
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value and the plugin only needs to inject cmdlineArgs.
|
||||
const args = ctx.get('cmdlineArgs')
|
||||
@@ -114,45 +103,54 @@ export function parseCmdline<T>(
|
||||
if (args === undefined || exit === undefined) {
|
||||
throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
|
||||
}
|
||||
program
|
||||
if (!hasAction(program)) {
|
||||
throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`)
|
||||
}
|
||||
configureExitAndOutput(program)
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and the action's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any command in the tree declares an action handler.
|
||||
*
|
||||
* The `Command` type cannot express the action precondition, so the handler is
|
||||
* read structurally (as {@link isCommanderError} reads commander's control-flow
|
||||
* errors): without this guard, a program that forgot its action would parse
|
||||
* successfully, publish nothing, and surface only as dependent rows pending on
|
||||
* the absent service.
|
||||
* @param command - the command whose tree is inspected.
|
||||
* @returns true when the command or any registered subcommand has an action.
|
||||
*/
|
||||
function hasAction(command: Command): boolean {
|
||||
if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
|
||||
return command.commands.some(hasAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route every command's exit and output through the launcher adapter.
|
||||
*
|
||||
* Commander copies `exitOverride` and output configuration into a subcommand
|
||||
* only at registration, so a root-only override would let an
|
||||
* already-registered subcommand's rejection write to the process streams and
|
||||
* call `process.exit` directly, bypassing `ctx.appExit`.
|
||||
* @param command - the root of the command tree to configure.
|
||||
*/
|
||||
function configureExitAndOutput(command: Command): void {
|
||||
command
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: text => void internals.stdout.write(text),
|
||||
writeErr: text => void internals.stderr.write(text),
|
||||
})
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
return plan(program, ctx)
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and a plan's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on a row this composition ships disabled, because this invocation asked
|
||||
* for it (`dsh web --dev` and its client-plugin reload chain).
|
||||
*
|
||||
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
|
||||
* prefixed id it then fails to resolve — so a conditional row ships disabled
|
||||
* and a row mounted beside it enables it after startup resolves the invocation.
|
||||
* The Loader keeps that activation in memory, separate from serialized options,
|
||||
* so reapplying the composition cannot restore the invocation's row to disabled.
|
||||
* @param ctx - plugin context whose Loader tree carries the row.
|
||||
* @param id - the row id.
|
||||
* @returns nothing once the row has started or is waiting for its dependencies.
|
||||
* @throws when the Loader or named row is absent.
|
||||
*/
|
||||
export async function enableRow(ctx: Context, id: string): Promise<void> {
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
|
||||
const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
|
||||
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
|
||||
await entry.enableRuntime()
|
||||
for (const child of command.commands) configureExitAndOutput(child)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,9 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
|
||||
} from '../src/index.ts'
|
||||
import { internals, parseCmdline, provideCmdline } from '../src/index.ts'
|
||||
|
||||
/** Every value one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
@@ -45,8 +43,8 @@ function demoCommand(): Command {
|
||||
return new Command().name('demo').exitOverride().option('--port <port>', 'listen port')
|
||||
}
|
||||
|
||||
/** The fixture app's plan: the resolved values its rows read. */
|
||||
const demoPlan: CmdlinePlan<{ port?: number }> = (program) => {
|
||||
/** The fixture app's action body: the resolved values its rows read. */
|
||||
const resolveDemo = (program: Command): { port?: number } => {
|
||||
const port = program.opts<{ port?: string }>().port
|
||||
if (port === undefined) return {}
|
||||
if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
|
||||
@@ -60,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source })
|
||||
* Mount a two-row composition the way a profile boot does: both rows at once,
|
||||
* with Loader ordering config resolution from their injections.
|
||||
* @param args - the invocation's inner arguments.
|
||||
* @param plan - the app's plan; defaults to the fixture's own.
|
||||
* @param resolve - the app's action body; defaults to the fixture's own.
|
||||
* @returns the booted fixture.
|
||||
*/
|
||||
async function bootFixture(
|
||||
args: string[],
|
||||
plan: CmdlinePlan = demoPlan,
|
||||
resolve: (program: Command) => unknown = resolveDemo,
|
||||
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
|
||||
): Promise<Fixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
|
||||
@@ -90,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
|
||||
const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
|
||||
globals.__observed = observed
|
||||
globals.__provideDemoArgs = (ctx: Context) => {
|
||||
const values = parseCmdline(ctx, demoCommand(), plan)
|
||||
if (values !== undefined) ctx.provide('demoStartup', values)
|
||||
const program = demoCommand()
|
||||
program.action(() => { ctx.provide('demoStartup', resolve(program)) })
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
|
||||
// The composition, exactly as a profile delivers one: include patches whose
|
||||
@@ -135,7 +134,7 @@ describe('parseCmdline', () => {
|
||||
})
|
||||
|
||||
it('recognizes the Loader object form of a provider-service injection', async () => {
|
||||
const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true })
|
||||
const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true })
|
||||
expect(observed.started).toEqual({ port: 8080 })
|
||||
})
|
||||
|
||||
@@ -146,97 +145,36 @@ describe('parseCmdline', () => {
|
||||
expect(observed.exits).toEqual([0])
|
||||
})
|
||||
|
||||
it('rejects the invocation from the plan without starting the app', async () => {
|
||||
it('rejects the invocation from the action without starting the app', async () => {
|
||||
const { observed } = await bootFixture(['--port', 'abc'])
|
||||
expect(observed.out).toContain('--port must be a number')
|
||||
expect(observed.started).toBeUndefined()
|
||||
expect(observed.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('rethrows a plan failure that is not commander asking to exit', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
const plan: CmdlinePlan = () => { throw new Error('plan exploded') }
|
||||
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded')
|
||||
it('rethrows an action failure that is not commander asking to exit', async () => {
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
const program = demoCommand().action(() => { throw new Error('action exploded') })
|
||||
expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded')
|
||||
})
|
||||
|
||||
it('rethrows a thrown value that is not an object at all', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
const plan: CmdlinePlan = () => {
|
||||
const thrown: unknown = 'plan threw a string'
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
const program = demoCommand().action(() => {
|
||||
const thrown: unknown = 'action threw a string'
|
||||
throw thrown
|
||||
}
|
||||
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string')
|
||||
})
|
||||
|
||||
it('returns values without inspecting Loader rows or owning a service', async () => {
|
||||
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
|
||||
expect(parseCmdline(ctx, demoCommand())).toEqual({})
|
||||
expect(ctx.get('demoStartup')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableRow', () => {
|
||||
it('enables the named Loader row and fails loud when the Loader or row is absent', async () => {
|
||||
const withoutLoader = new Context()
|
||||
await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
|
||||
|
||||
const ctx = new Context()
|
||||
let enabled = false
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { enabled = true },
|
||||
}],
|
||||
} as never)
|
||||
await enableRow(ctx, 'client-hmr')
|
||||
expect(enabled).toBe(true)
|
||||
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
|
||||
})
|
||||
|
||||
it('keeps invocation-only activation through config reapplication', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-'))
|
||||
const observed = { starts: 0, stops: 0 }
|
||||
;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed
|
||||
writeFileSync(join(dir, 'conditional.mjs'), `
|
||||
export function apply(ctx) {
|
||||
globalThis.__runtimeEnableObserved.starts += 1
|
||||
ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 })
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: conditional',
|
||||
` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`,
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional')
|
||||
const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include')
|
||||
expect(conditional).toBeDefined()
|
||||
expect(include?.fiber).toBeDefined()
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 0, stops: 0 })
|
||||
expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string')
|
||||
})
|
||||
|
||||
await enableRow(ctx, 'conditional')
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
|
||||
await include!.fiber!.update(include!.options.config, true)
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
it('runs the action without inspecting Loader rows or owning a service', async () => {
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
let values: unknown
|
||||
const program = demoCommand()
|
||||
program.action(() => { values = resolveDemo(program) })
|
||||
parseCmdline(ctx, program)
|
||||
expect(values).toEqual({})
|
||||
expect(ctx.get('demoStartup')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,6 +187,28 @@ describe('provideCmdline', () => {
|
||||
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
|
||||
})
|
||||
|
||||
it('refuses at load a program in which no command declares an action', async () => {
|
||||
const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true })
|
||||
expect(() => { parseCmdline(ctx, demoCommand()) })
|
||||
.toThrow('no command in the program declares an action')
|
||||
})
|
||||
|
||||
it('routes a pre-registered subcommand rejection through the launcher exit request', () => {
|
||||
const ctx = new Context()
|
||||
const exits: number[] = []
|
||||
let err = ''
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) })
|
||||
// The root declares no action of its own: the tree-wide guard accepts the
|
||||
// subcommand's, and the subcommand inherits the exit and output routing.
|
||||
const program = new Command().name('demo')
|
||||
const child = program.command('serve')
|
||||
child.action(() => { child.error('error: serve rejected') })
|
||||
parseCmdline(ctx, program)
|
||||
expect(err).toContain('serve rejected')
|
||||
expect(exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('fails loud when a parser runs without the launcher values', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { parseCmdline(ctx, demoCommand()) })
|
||||
@@ -258,8 +218,15 @@ describe('provideCmdline', () => {
|
||||
it('lets multiple parsers read the same immutable snapshot', () => {
|
||||
const ctx = new Context()
|
||||
provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
|
||||
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
|
||||
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
|
||||
const parseOnce = (): unknown => {
|
||||
let values: unknown
|
||||
const program = demoCommand()
|
||||
program.action(() => { values = resolveDemo(program) })
|
||||
parseCmdline(ctx, program)
|
||||
return values
|
||||
}
|
||||
expect(parseOnce()).toEqual({ port: 8080 })
|
||||
expect(parseOnce()).toEqual({ port: 8080 })
|
||||
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/bundle/base/README.md
|
||||
README.md: 8b0db20274036a2601da19617a35e6bf4aeb30ca
|
||||
README.zh.md: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782
|
||||
README.md: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b
|
||||
README.zh.md: 2c6ff8513bae2b7d4b595733e83223bb7af34780
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code.
|
||||
|
||||
Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it.
|
||||
The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled.
|
||||
|
||||
The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。
|
||||
|
||||
启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。
|
||||
patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机看到的是被禁用的 pwsh 行。
|
||||
|
||||
行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。
|
||||
|
||||
|
||||
@@ -171,9 +171,14 @@
|
||||
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
disabled: !!js process.platform === 'win32'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
|
||||
- id: approval
|
||||
name: '@deepseek-ai/dsh-user-approval'
|
||||
config:
|
||||
@@ -198,6 +203,11 @@
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
disabled: !!js process.platform === 'win32'
|
||||
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
|
||||
- id: tool-tasks
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-base",
|
||||
"description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
@@ -23,7 +23,6 @@
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./windows.cordis.patch.yml": "./windows.cordis.patch.yml",
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -31,7 +30,6 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"cordis.patch.yml",
|
||||
"windows.cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* field must name a real, parseable patch list.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
describe('dsh-base bundle', () => {
|
||||
it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => {
|
||||
@@ -42,34 +43,37 @@ describe('dsh-base bundle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => {
|
||||
it('gates each shell stack by platform with a symmetric disabled expression', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
const parsed = yaml.load(
|
||||
readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'),
|
||||
readFileSync(resolve(root, 'cordis.patch.yml'), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
) as {
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
insert?: { id?: string; name?: string }[]
|
||||
config?: { policy?: string }
|
||||
}[]
|
||||
const disables = parsed
|
||||
.filter(patch => patch.disabled === true)
|
||||
.map(patch => patch.id)
|
||||
// Only the POSIX bash stack is disabled: the Windows roster confines the
|
||||
// pwsh executor through the ACL runner chain, so the sandbox/policy rows,
|
||||
// the permission switcher, fs-sandbox, and the approval service all stay
|
||||
// enabled exactly as on POSIX — only the shell is swapped.
|
||||
expect(disables).toEqual(['bash-sandbox', 'tool-bash'])
|
||||
const inserted = parsed
|
||||
.flatMap(patch => patch.insert ?? [])
|
||||
.map(row => row.id)
|
||||
expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh'])
|
||||
// The patch no longer touches the permission/approval surface at all.
|
||||
expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined()
|
||||
expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined()
|
||||
)
|
||||
if (!Array.isArray(parsed)) throw new TypeError('base patch must parse to a patch list')
|
||||
const rows = parsed.flatMap((patch): Record<string, unknown>[] =>
|
||||
typeof patch === 'object' && patch !== null
|
||||
? (patch as { insert?: Record<string, unknown>[] }).insert ?? []
|
||||
: [],
|
||||
)
|
||||
// Symmetric gating: each stack's executor and tool rows carry the same
|
||||
// platform fact, inverted between the bash and pwsh twins, so exactly one
|
||||
// shell stack mounts per host. Evaluate with a platform-scoped context
|
||||
// (the `with` scope shadows the global `process`) so both outcomes pin on
|
||||
// every host.
|
||||
for (const [id, win32, linux] of [
|
||||
['bash-sandbox', true, false],
|
||||
['tool-bash', true, false],
|
||||
['pwsh-sandbox', false, true],
|
||||
['tool-pwsh', false, true],
|
||||
] as const) {
|
||||
const row = rows.find(candidate => candidate.id === id)
|
||||
if (row === undefined) throw new Error(`base patch must mount ${id}`)
|
||||
const expression = (row.disabled as { __jsExpr?: string } | undefined)?.__jsExpr
|
||||
if (expression === undefined) throw new Error(`${id} must gate on a !!js disabled expression`)
|
||||
expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
|
||||
expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(linux)
|
||||
}
|
||||
// The platform layer folded into these rows: no separate patch file ships.
|
||||
expect(existsSync(resolve(root, 'windows.cordis.patch.yml'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# The dsh-base Windows platform layer: applied by the dsh launcher on win32
|
||||
# hosts, between the bundle layers and the user layers. Windows confines
|
||||
# through the ACL restricted-token runner (the win32 chain of
|
||||
# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped
|
||||
# stack is the SANDBOXED PowerShell executor plus the full permission
|
||||
# surface: sandbox/sandbox-policy enforce the file-effect policy, the
|
||||
# permission switcher and the approval service run exactly as on POSIX, and
|
||||
# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting
|
||||
# dsh-fs-local alongside it would double-register ctx.fs and fail the load.
|
||||
# Only the POSIX bash
|
||||
# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner.
|
||||
# A Windows host that prefers the unconfined local pwsh executor or full
|
||||
# access overrides these rows through its profile or home cordis.patch.yml.
|
||||
# The bash-restore recipe must be complete: disable pwsh-sandbox and
|
||||
# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor
|
||||
# families register the same 'bash' service, so re-enabling the bash rows
|
||||
# while pwsh-sandbox stays inserted fails loud at load on a duplicate
|
||||
# registration.
|
||||
|
||||
- id: bash-sandbox
|
||||
disabled: true
|
||||
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pwsh-sandbox
|
||||
name: '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
@@ -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/bundle/headless/README.md
|
||||
README.md: 31a4894dbb191d2244371ca7272339e96e253053
|
||||
README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
|
||||
README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3
|
||||
README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
|
||||
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
|
||||
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
|
||||
- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
|
||||
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -17,4 +17,4 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
|
||||
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子。
|
||||
- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
persona: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
# One-shot runs never watch or reload their composition.
|
||||
# The shared module-reload HMR row stays off; the launcher's watch-only
|
||||
# fallback still keeps the user patch layers live until the run exits.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-headless",
|
||||
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
|
||||
"version": "0.0.1-rc.1",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
|
||||
@@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import carries the loader Context merge for the settlement await.
|
||||
// Empty type imports carry the loader Context merge for the settlement await
|
||||
// and the cmdline Context merge for the appExit host value.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'headless-runner'
|
||||
@@ -41,22 +43,18 @@ interface RunOutcome {
|
||||
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-facing effects of one run, injectable for tests. The launcher owns
|
||||
* bounded tree shutdown and wires `exit()` to it.
|
||||
*/
|
||||
export interface HeadlessIo {
|
||||
/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
|
||||
interface HeadlessIo {
|
||||
stdout: { write(chunk: string): unknown }
|
||||
stderr: { write(chunk: string): unknown }
|
||||
/** Request process exit with `code` after the tree disposes. */
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Process-facing effects provided before the headless tree mounts. */
|
||||
headlessIo?: HeadlessIo
|
||||
}
|
||||
/** The process streams the runner writes to; tests substitute captures. */
|
||||
export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/** Aggregate the last assistant text and turn outcome in one owned interval. */
|
||||
@@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
|
||||
/**
|
||||
* Mount the one-shot direct driver.
|
||||
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
|
||||
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
||||
* @param config - validated task config.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const io = ctx.headlessIo
|
||||
if (io === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value, never an injected dependency.
|
||||
const exit = ctx.get('appExit')
|
||||
if (exit === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
|
||||
}
|
||||
const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
|
||||
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
|
||||
}
|
||||
|
||||
@@ -41,22 +41,17 @@ Examples:
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the parsed command line into the runner's task.
|
||||
* @param program - the parsed headless command.
|
||||
* @returns the runner's service value.
|
||||
*/
|
||||
function planHeadlessStartup(program: Command): HeadlessStartupValues {
|
||||
const task = program.args.join(' ')
|
||||
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
|
||||
return { task }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and provide the one-shot task as an ordinary Cordis service.
|
||||
* Parse and provide the one-shot task as an ordinary Cordis service. The
|
||||
* command's action publishes the task; a missing or whitespace-only task is a
|
||||
* usage error, so on rejection (and on `--help`) nothing is provided.
|
||||
* @param ctx - plugin context carrying the command line.
|
||||
* @returns nothing once the task is provided, or when the command requested exit.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup)
|
||||
if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values)
|
||||
const program = headlessCommand()
|
||||
program.action(() => {
|
||||
const task = program.args.join(' ')
|
||||
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
|
||||
ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues)
|
||||
})
|
||||
parseCmdline(ctx, program)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
@@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { apply, Config, type HeadlessIo } from '../src/index.ts'
|
||||
import { apply, Config, internals } from '../src/index.ts'
|
||||
|
||||
const originalInternals = { ...internals }
|
||||
afterEach(() => { Object.assign(internals, originalInternals) })
|
||||
|
||||
interface Script {
|
||||
before?(session: Session): void
|
||||
@@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{
|
||||
let err = ''
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
const io: HeadlessIo = {
|
||||
stdout: { write: (chunk: string) => { out += chunk; return true } },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: (code) => { order.push('exit'); resolve(code) },
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
|
||||
})
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
return { code: await exited, out, err, order }
|
||||
@@ -181,12 +181,10 @@ describe('headless runner', () => {
|
||||
it('reports a direct Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('appExit', resolve)
|
||||
})
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
@@ -200,12 +198,10 @@ describe('headless runner', () => {
|
||||
it('stringifies a non-Error Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('appExit', resolve)
|
||||
})
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
@@ -224,11 +220,9 @@ describe('headless runner', () => {
|
||||
it('abandons a run when the tree is disposed during Loader settlement', async () => {
|
||||
const ctx = new Context()
|
||||
let exited = false
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
exit: () => { exited = true },
|
||||
} satisfies HeadlessIo)
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: () => true }
|
||||
ctx.provide('appExit', () => { exited = true })
|
||||
const services = ctx.plugin((child: Context) => {
|
||||
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
child.provide('sessions', {} as never)
|
||||
@@ -246,9 +240,9 @@ describe('headless runner', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||
it('fails loud without the launcher-provided exit request', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
|
||||
})
|
||||
|
||||
it('validates config: the task is required', () => {
|
||||
|
||||
@@ -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/bundle/web-app/README.md
|
||||
README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd
|
||||
README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68
|
||||
README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7
|
||||
README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered.
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns.
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (the port is a boot fact), so it does not invalidate the cache across turns.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user