Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Local implementation of the bash executor seam over the subprocess
|
||||
* seam. Each command runs as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; this executor owns command defaulting, deadlines
|
||||
* and cause classification, the model-friendly terminal environment, and the
|
||||
* model-facing stdout/stderr merge for background reads. Execution policy
|
||||
* belongs in `tools/pre-execute` or a sandboxing executor.
|
||||
* seam. Public commands run as `bash -c` in a managed process group spawned
|
||||
* through `ctx.subprocess`; subclasses may reuse the same mechanics with an
|
||||
* explicit argv. This executor owns command defaulting, deadlines and cause
|
||||
* classification, the model-friendly terminal environment, and the model-facing
|
||||
* stdout/stderr merge for background reads. Execution policy belongs in
|
||||
* `tools/pre-execute` or a sandboxing executor.
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
@@ -137,13 +138,18 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
/** Map one resolved bash spec and explicit argv onto a fully-specified subprocess spawn. */
|
||||
// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state.
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
private spawnSpec(
|
||||
spec: BashExecSpec,
|
||||
argv: readonly string[],
|
||||
stdoutMaxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: ['bash', '-c', spec.command],
|
||||
argv,
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -171,9 +177,21 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return this.runArgv(spec, ['bash', '-c', spec.command])
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an explicit argv with the foreground lifecycle, environment, output,
|
||||
* timeout, and cancellation semantics of this executor. Subclasses use this
|
||||
* after replacing the public command's shell argv at an execution boundary.
|
||||
* @param spec - resolved execution settings and caller-owned command metadata.
|
||||
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
|
||||
* @returns the settled foreground result with collected output and cause facts.
|
||||
*/
|
||||
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
|
||||
const outcome = await handle.done
|
||||
const collected = LocalBashExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
@@ -190,8 +208,21 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return this.startArgv(spec, ['bash', '-c', spec.command])
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an explicit argv with the background lifecycle, environment, output,
|
||||
* cancellation, and process-tree ownership semantics of this executor.
|
||||
* Subclasses use this after replacing the public command's shell argv at an
|
||||
* execution boundary.
|
||||
* @param spec - resolved execution settings and caller-owned command metadata.
|
||||
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
|
||||
* @returns the live background handle; spawn rejection settles it as killed.
|
||||
*/
|
||||
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
|
||||
const collected = LocalBashExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
@@ -216,12 +247,12 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
this.onProcessDone(proc, spawnFailureNote, true, error)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
@@ -261,8 +292,10 @@ export class LocalBashExecutor extends BashExecutor {
|
||||
* empty.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
* @param _spawnFailed - whether the subprocess promise rejected before a process started.
|
||||
* @param _spawnError - the original spawn rejection reason, which may itself be undefined.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
|
||||
}
|
||||
|
||||
export default LocalBashExecutor
|
||||
|
||||
@@ -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-sandbox/README.md
|
||||
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
|
||||
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb
|
||||
README.md: 2f69ea66251f00c74779f1decc69abc6003a4398
|
||||
README.zh.md: fc4afb554442dfaf806292f30ddf9c495c427831
|
||||
|
||||
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; result-classification helpers stay internal.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned argv directly. With the shipped native runners, the inner Bash retains shell semantics and evaluates `BASH_ENV` only after the runner establishes confinement. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|
||||
Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
|
||||
- **Runner attribution is conservative.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with positive provenance for provider argv[0]. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessService` synchronously throws the same provenanced `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
@@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
|
||||
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` argv[0] evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection. When present, the appended `Runner failure: <detail>` is the authoritative diagnosis; the preceding backend-install text is the generic `SANDBOX_UNAVAILABLE` prefix.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -86,5 +86,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
|
||||
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
|
||||
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
|
||||
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`; a provenanced synchronous `SubprocessService` throw instead fails `start()` immediately.
|
||||
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。
|
||||
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。
|
||||
包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;结果分类 helper 保留在内部。
|
||||
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,并直接 spawn 返回的 argv。使用随附的原生 runner 时,内层 Bash 保留 shell 语义,并且只在 runner 建立约束后才求值 `BASH_ENV`。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。
|
||||
|
||||
| 模式 | 文件影响 |
|
||||
|---|---|
|
||||
@@ -17,7 +17,7 @@
|
||||
语义:
|
||||
|
||||
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
|
||||
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT` 或 `EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true` 和 `denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT`/`EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
|
||||
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
|
||||
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
|
||||
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
|
||||
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。可归因于 runner 的 spawn 失败会以原始 spawn 错误作为详细信息;没有 `ENOENT`/`EACCES` argv[0] 证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。如果追加了 `Runner failure: <detail>`,它就是权威诊断;前面的后端安装文本只是通用的 `SANDBOX_UNAVAILABLE` 前缀。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -86,5 +86,5 @@
|
||||
|
||||
- **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
|
||||
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
|
||||
- **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。
|
||||
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现;同步 `SubprocessService` 抛出带有来源信息的 `ENOENT`/`EACCES` 时,则会使 `start()` 立即失败。
|
||||
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
|
||||
|
||||
@@ -1,18 +1,61 @@
|
||||
/**
|
||||
* Internal shell-quoting and sandbox-result classification helpers.
|
||||
* Internal sandbox-result classification helpers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word.
|
||||
* @param text - raw argv element to preserve through the outer shell parse.
|
||||
* @returns the quoted shell word.
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,13 +69,37 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's runner-failure dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
|
||||
* @returns whether the failed run matches that runner-failure dialect.
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
/**
|
||||
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
|
||||
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
|
||||
* mode, enforcement, and denial facts. Runner failure means the command never
|
||||
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
|
||||
* processes carry `runnerFailed`. The tool owns approval and passes a complete
|
||||
* per-call policy.
|
||||
* mode, enforcement, and denial facts. Positive runner-launch evidence means
|
||||
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
|
||||
* background processes carry `runnerFailed`; other spawn rejections retain
|
||||
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
ConfinedArgv,
|
||||
ConfinedSandboxMode,
|
||||
RunnerFailureRule,
|
||||
SandboxEnforcement,
|
||||
SandboxExecutionPolicy,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
@@ -51,7 +59,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -83,11 +93,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial because the command did not run. Throw the
|
||||
// same fail-closed error as confine-time discovery with the first stderr line.
|
||||
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
|
||||
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await this.runArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// An upstream abort remains cancellation even when it prevents spawn.
|
||||
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Runner failure outranks denial because the command did not run. Carry
|
||||
// the matched fatal line, not an informational line that preceded it.
|
||||
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
|
||||
if (runnerFailure !== undefined) {
|
||||
throw new SandboxUnavailableError(mode, runnerFailure.detail)
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
@@ -96,11 +117,29 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
let proc: BashProcess
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// LocalSubprocessService reports provenanced ENOENT/EACCES through async
|
||||
// `done` rejection; this covers alternatives that throw that shape synchronously.
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, {
|
||||
mode,
|
||||
enforcement,
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
@@ -108,12 +147,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* Stamp per-process sandbox facts before `done` settles. Full-access processes
|
||||
* have no facts; signal deaths are not denials.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string): void {
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// Runner failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnFailed
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
@@ -121,30 +163,19 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr)
|
||||
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one shell command via the `ctx.sandbox` provider: hand over the
|
||||
* exact `['bash', '-c', command]` argv this executor would spawn, get back
|
||||
* the confined argv, and re-assemble it into the `exec …` command string
|
||||
* the inherited spawn path runs (the outer `bash -c` the subprocess service spawns
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
* Wrap one shell command via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param command - shell source for the confined inner `bash -c`.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(command: string, policy: SandboxPolicy): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
denialSignatures: confined.denialSignatures,
|
||||
runnerFailureSignatures: confined.runnerFailureSignatures,
|
||||
}
|
||||
private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
270
packages/bash/bash-sandbox/tests/partial-landlock.spec.ts
Normal file
270
packages/bash/bash-sandbox/tests/partial-landlock.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Deterministic real-process proofs for runner classification: the real local
|
||||
* provider and sandbox bash executor exercise direct runner-spawn failures
|
||||
* and a POSIX fake Landlock launcher that prints its notice before exec.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const FATAL_PREFIX = 'landlock-run: '
|
||||
const FATAL = `${FATAL_PREFIX}landlock ruleset error: Invalid argument`
|
||||
|
||||
const contexts: Context[] = []
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
|
||||
async function fakeLauncher(fatalExit?: number): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
|
||||
tempDirs.push(dir)
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
const fatalBranch = fatalExit === undefined ? '' : `printf '%s\\n' '${FATAL}' >&2\nexit ${fatalExit}\n`
|
||||
await writeFile(launcher, `#!/bin/sh
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ro|--rw) shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) printf '%s\\n' '${FATAL_PREFIX}usage error: unexpected fake argument' >&2; exit ${LAUNCHER_FAILURE_EXIT} ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\\n' '${NOTICE}' >&2
|
||||
${fatalBranch}exec "$@"
|
||||
`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
async function setup(fatalExit?: number): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = {
|
||||
platform: 'linux',
|
||||
probeBwrap: () => false,
|
||||
probeLandlock: () => 'partial',
|
||||
landlockLauncher: await fakeLauncher(fatalExit),
|
||||
}
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
async function setupConfiguredRunner(runner: string): Promise<SandboxBashExecutor> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LocalSandboxProvider, {
|
||||
runnerCommand: [runner],
|
||||
runnerFailureSignatures: ['configured-runner: fatal'],
|
||||
})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
describe('partial Landlock runner-failure classification', () => {
|
||||
it.each(['missing', 'unexecutable', 'missing-interpreter'] as const)('classifies a %s configured runner through the direct spawn error channel', async (kind) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-unusable-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, `${kind}-runner`)
|
||||
if (kind === 'unexecutable') await writeFile(runner, '#!/bin/sh\nexit 0\n', { mode: 0o644 })
|
||||
if (kind === 'missing-interpreter') {
|
||||
await writeFile(runner, '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
}
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(runner)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['bare-name', 'relative'] as const)(
|
||||
'classifies a %s runner whose shebang interpreter is missing',
|
||||
async (form) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-argv-form-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const filename = 'missing-interpreter-runner'
|
||||
const runner = form === 'bare-name' ? filename : `./${filename}`
|
||||
await writeFile(join(dir, filename), '#!/dsh-definitely-missing-sandbox-interpreter\nexit 0\n', { mode: 0o755 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
const request = form === 'bare-name'
|
||||
? { command: 'true', env: { PATH: dir } }
|
||||
: { command: 'true', workdir: dir }
|
||||
|
||||
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
|
||||
// argv[0] in this spawn error rather than resolving it to an absolute path.
|
||||
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
|
||||
|
||||
const task = bash.start(bash.resolve(request))
|
||||
await task.done
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`)
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
runnerFailed: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps a real malformed executable ordinary across no-shebang spawn behavior', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-malformed-sandbox-runner-'))
|
||||
tempDirs.push(dir)
|
||||
const runner = join(dir, 'malformed-runner')
|
||||
await writeFile(runner, 'not a native executable or shebang script\n', { mode: 0o755 })
|
||||
const bash = await setupConfiguredRunner(runner)
|
||||
const request = { command: 'true' }
|
||||
|
||||
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
|
||||
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
|
||||
// provenanced ENOENT/EACCES evidence required for runner attribution.
|
||||
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
if (foreground instanceof Error) {
|
||||
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect((foreground as { path?: unknown }).path).toBeUndefined()
|
||||
|
||||
let background: unknown
|
||||
try {
|
||||
bash.start(bash.resolve(request))
|
||||
} catch (error) {
|
||||
background = error
|
||||
}
|
||||
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect((background as { path?: unknown }).path).toBeUndefined()
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} else {
|
||||
expect(foreground).toMatchObject({
|
||||
exitCode: 127,
|
||||
signal: null,
|
||||
sandbox: { mode: 'read-only', denied: false, enforcement: 'full' },
|
||||
})
|
||||
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
|
||||
|
||||
const background = bash.start(bash.resolve(request))
|
||||
await background.done
|
||||
expect(background.status).toBe('completed')
|
||||
expect(background.exitCode).toBe(127)
|
||||
expect(background.signal).toBeNull()
|
||||
expect(background.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
const output = background.readOutput().delta
|
||||
expect(output.startsWith('[stderr]\n')).toBe(true)
|
||||
expect(output.length).toBeGreaterThan('[stderr]\n'.length)
|
||||
expect(output).not.toContain('spawn failed:')
|
||||
}
|
||||
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, LAUNCHER_FAILURE_EXIT])(
|
||||
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
|
||||
async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
},
|
||||
)
|
||||
|
||||
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
|
||||
const bash = await setup(exitCode)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
|
||||
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
|
||||
expect((error as Error).message).not.toContain(NOTICE)
|
||||
})
|
||||
|
||||
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
|
||||
const bash = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
})
|
||||
|
||||
it('applies the same evidence rule to notice-only background exits', async () => {
|
||||
const bash = await setup()
|
||||
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
|
||||
const task = bash.start(bash.resolve({ command }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a background notice plus child Permission denied as denial', async () => {
|
||||
const bash = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
expect(task.readOutput().delta).toContain(NOTICE)
|
||||
})
|
||||
|
||||
it('makes a background fatal line outrank denial text after the notice', async () => {
|
||||
const bash = await setup(LAUNCHER_FAILURE_EXIT)
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'partial',
|
||||
runnerFailed: true,
|
||||
})
|
||||
const output = task.readOutput().delta
|
||||
expect(output).toContain(NOTICE)
|
||||
expect(output).toContain(FATAL)
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
* the Unix denial signature used by the classifier without requiring a real sandbox runner.
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -16,7 +16,8 @@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy }
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
@@ -30,12 +31,19 @@ interface ConfineCall {
|
||||
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
|
||||
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
|
||||
|
||||
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = ['fake-runner: '] as const
|
||||
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
|
||||
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
|
||||
|
||||
/** Provider argv[0] forms that all share the caller-owned cwd spawn precondition. */
|
||||
const RUNNER_FORMS = [
|
||||
['absolute', process.execPath],
|
||||
['bare', 'node'],
|
||||
['relative', './sandbox-runner'],
|
||||
] as const
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
@@ -90,15 +98,49 @@ describe('the provider hand-off', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
|
||||
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
|
||||
// the sentinel only prints if the executor spawned the WRAPPED argv.
|
||||
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
it('hands the provider\'s returned argv directly to ctx.subprocess.spawn', async () => {
|
||||
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
|
||||
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
|
||||
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
|
||||
expect(result.stdout.text).toBe('1')
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('starts a non-Bash runner before the confined inner Bash evaluates BASH_ENV', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-bash-env-order-'))
|
||||
const hook = join(dir, 'hook.sh')
|
||||
const order = join(dir, 'order.txt')
|
||||
writeFileSync(hook, 'printf "hook\\n" >> "$DSH_ORDER_FILE"\n')
|
||||
const runnerScript = [
|
||||
'const { appendFileSync } = require("node:fs");',
|
||||
'const { spawnSync } = require("node:child_process");',
|
||||
'appendFileSync(process.env.DSH_ORDER_FILE, "runner\\n");',
|
||||
'const child = spawnSync(process.argv[1], process.argv.slice(2), { env: process.env, stdio: "inherit" });',
|
||||
'process.exit(child.status ?? 125);',
|
||||
].join('')
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [process.execPath, '-e', runnerScript, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: { DSH_ORDER_FILE: order },
|
||||
}))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(readFileSync(order, 'utf8')).toBe('runner\nhook\n')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
@@ -120,9 +162,6 @@ describe('the provider hand-off', () => {
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shellQuote survives embedded single quotes (the argv re-assembly primitive)', () => {
|
||||
expect(shellQuote('a\'b')).toBe(String.raw`'a'\''b'`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail closed', () => {
|
||||
@@ -132,6 +171,120 @@ describe('fail closed', () => {
|
||||
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(spec)).toThrow(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('preserves an already-aborted foreground call as cancellation', async () => {
|
||||
const { bash } = await setup()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('caller cancelled before spawn')
|
||||
controller.abort(reason)
|
||||
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it.each(RUNNER_FORMS)(
|
||||
'keeps an invalid workdir ordinary with the %s provider-runner form',
|
||||
async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an invalid workdir ordinary when danger-full-access bypasses the provider', async () => {
|
||||
const { bash } = await setup({ mode: 'danger-full-access' })
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toMatchObject({ code: 'ENOENT' })
|
||||
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Node-shaped synchronous ENOEXEC ordinary in run() and start()', async () => {
|
||||
const runner = join(spillDir, 'malformed-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
|
||||
})
|
||||
|
||||
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
|
||||
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
|
||||
let background: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true' }))
|
||||
} catch (error) {
|
||||
background = error
|
||||
}
|
||||
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
|
||||
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
})
|
||||
|
||||
it('classifies a synchronous SubprocessService EACCES with exact runner provenance', async () => {
|
||||
const runner = join(spillDir, 'unexecutable-runner')
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
// This pins an alternative SubprocessService's synchronous seam, not the
|
||||
// shipped local behavior.
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
|
||||
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
|
||||
})
|
||||
|
||||
await expect(bash.run(bash.resolve({ command: 'true' })))
|
||||
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
|
||||
expect(() => bash.start(bash.resolve({ command: 'true' })))
|
||||
.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
|
||||
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
|
||||
const runner = './sandbox-runner'
|
||||
const { ctx, bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
const workdir = join(parent, 'missing')
|
||||
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
|
||||
try {
|
||||
let thrown: unknown
|
||||
try {
|
||||
bash.start(bash.resolve({ command: 'true', workdir }))
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBe(failure)
|
||||
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
@@ -233,15 +386,134 @@ describe('classifyDenial', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
it.each(['EACCES', 'ENOENT'])(
|
||||
'attributes executable-class spawn code %s to argv[0] once cwd ambiguity is eliminated',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ENOEXEC', 'ENOTDIR', 'EPERM'])(
|
||||
'keeps unproven executable code %s ordinary despite synthetic argv[0] fields',
|
||||
(code) => {
|
||||
const runner = join(spillDir, 'runner')
|
||||
const error = Object.assign(new Error('spawn failed'), { code, syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, process.cwd())).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('requires a usable caller cwd before classifying absolute, bare, or relative runners', () => {
|
||||
const missingWorkdir = join(spillDir, 'missing-workdir')
|
||||
for (const [, runner] of RUNNER_FORMS) {
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
|
||||
expect(isRunnerSpawnFailure(error, runner, missingWorkdir)).toBe(false)
|
||||
}
|
||||
const fileWorkdir = join(spillDir, 'not-a-workdir')
|
||||
writeFileSync(fileWorkdir, '')
|
||||
const error = Object.assign(new Error('spawn failed'), { code: 'ENOTDIR', syscall: 'spawn node', path: 'node' })
|
||||
expect(isRunnerSpawnFailure(error, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects resource, non-spawn, mismatched-program, and unstructured failures', () => {
|
||||
const missingRunner = join(spillDir, 'definitely-missing-runner')
|
||||
const spawnError = (code: unknown, syscall: unknown = `spawn ${missingRunner}`, path: unknown = missingRunner) =>
|
||||
Object.assign(new Error('spawn failed'), { code, syscall, path })
|
||||
const spawnErrorWithoutPath = (syscall: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('EMFILE'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOMEM'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError(2), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'open'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', process.execPath), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', 1), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT', 'spawn', ''), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnErrorWithoutPath('spawn other-runner'), missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(undefined, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, missingRunner, process.cwd())).toBe(false)
|
||||
expect(isRunnerSpawnFailure(spawnError('ENOENT'), undefined, process.cwd())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only syscall provenance compatible with the exact runner program', () => {
|
||||
const runner = join(spillDir, 'runner with spaces')
|
||||
const spawnError = (syscall: string, path?: string) =>
|
||||
Object.assign(new Error('spawn failed'), { code: 'ENOENT', syscall, path })
|
||||
|
||||
expect(isRunnerSpawnFailure(spawnError('spawn', runner), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`, runner), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError(`spawn ${runner}`), runner, process.cwd())).toBe(true)
|
||||
expect(isRunnerSpawnFailure(spawnError('spawn other-runner', runner), runner, process.cwd())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
|
||||
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
|
||||
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
|
||||
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
|
||||
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
|
||||
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps valid fatal signatures active beside an ignored empty entry', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const fatal = 'landlock-run: ruleset creation failed'
|
||||
const rules = [{
|
||||
allowedExitCodes: [125],
|
||||
fatalSignatures: ['', ' ', 'landlock-run: '],
|
||||
informationalLines: [notice],
|
||||
}]
|
||||
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
|
||||
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
|
||||
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
|
||||
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
|
||||
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
|
||||
.toEqual({ detail: `${notice}: extra detail` })
|
||||
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
|
||||
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'landlock-run: usage error: missing `-- <argv>...` command',
|
||||
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
|
||||
'landlock-run: cannot open rule path: /gone: No such file or directory',
|
||||
'landlock-run: landlock ruleset error: Invalid argument',
|
||||
'landlock-run: exec failed: Permission denied',
|
||||
'landlock-run: out of memory',
|
||||
'landlock-run: future fatal diagnostic',
|
||||
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
|
||||
const rules = [{
|
||||
allowedExitCodes: [125],
|
||||
fatalSignatures: ['landlock-run: '],
|
||||
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
|
||||
}]
|
||||
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
|
||||
})
|
||||
})
|
||||
|
||||
describe('result facts', () => {
|
||||
it.each([126, 127])('keeps a successfully launched wrapped child exit %i as an ordinary outcome', async (exitCode) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: ['env', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
|
||||
expect(result.exitCode).toBe(exitCode)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
|
||||
@@ -253,25 +525,66 @@ describe('result facts', () => {
|
||||
})
|
||||
|
||||
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
|
||||
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('background sandbox facts', () => {
|
||||
it('stamps facts and releases accounting when background spawn fails', async () => {
|
||||
const { bash } = await setup()
|
||||
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
|
||||
it.each(RUNNER_FORMS)('keeps an invalid-workdir rejection ordinary for the %s provider-runner form', async (_form, runner) => {
|
||||
const { bash } = await setup({}, argv => ({
|
||||
argv: [runner, ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: UNIX_SIGNATURES,
|
||||
runnerFailureRules: RUNNER_FAILURE,
|
||||
}))
|
||||
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
|
||||
try {
|
||||
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const emptyReader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
|
||||
}
|
||||
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
|
||||
pid: -1,
|
||||
stdin: undefined,
|
||||
stdout: undefined,
|
||||
stderr: undefined,
|
||||
collected: { stdout: emptyReader, stderr: emptyReader },
|
||||
// Arbitrary subprocess providers can reject without a value; that edge is the point of this test.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
done: Promise.reject(undefined),
|
||||
terminate: vi.fn(),
|
||||
waitForExit: async () => true,
|
||||
} satisfies SubprocessHandle)
|
||||
|
||||
const task = bash.start(bash.resolve({ command: 'true' }))
|
||||
await task.done
|
||||
|
||||
expect(task.status).toBe('killed')
|
||||
expect(task.readOutput().delta).toContain('spawn failed:')
|
||||
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
|
||||
expect(accounting.size).toBe(0)
|
||||
expect(task.readOutput().delta).toContain('spawn failed: undefined')
|
||||
expect(task.sandbox).toEqual({
|
||||
mode: 'read-only',
|
||||
denied: false,
|
||||
enforcement: 'full',
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
|
||||
@@ -284,7 +597,7 @@ describe('background sandbox facts', () => {
|
||||
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
|
||||
// The wrap's runner prefix on a failed run means the SANDBOX broke and
|
||||
// the command never ran — the late twin of the confine-time throw, with
|
||||
// the runner's own first stderr line carried as the cause.
|
||||
// the matched fatal stderr line carried as the cause.
|
||||
const { bash } = await setup()
|
||||
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
|
||||
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
@@ -315,7 +628,7 @@ describe('background sandbox facts', () => {
|
||||
let call = 0
|
||||
const { bash } = await setup({}, (argv) => {
|
||||
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
|
||||
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
|
||||
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
|
||||
})
|
||||
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
|
||||
const quick = bash.start(bash.resolve({ command: 'true' }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -76,6 +76,32 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
|
||||
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates BASH_ENV only after Seatbelt confines the inner Bash', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const outside = await tempDir(homedir())
|
||||
const hook = join(workdir, 'bash-env-hook.sh')
|
||||
const insideProbe = join(workdir, 'hook-ran.txt')
|
||||
const outsideProbe = join(outside, 'escaped.txt')
|
||||
await writeFile(hook, [
|
||||
'printf hook > "$DSH_BASH_ENV_INSIDE"',
|
||||
'printf escaped > "$DSH_BASH_ENV_OUTSIDE"',
|
||||
'',
|
||||
].join('\n'))
|
||||
const bash = await sandboxedBash(workdir, 'workspace-write')
|
||||
|
||||
await bash.run(bash.resolve({
|
||||
command: 'true',
|
||||
env: { BASH_ENV: hook },
|
||||
dshEnv: {
|
||||
DSH_BASH_ENV_INSIDE: insideProbe,
|
||||
DSH_BASH_ENV_OUTSIDE: outsideProbe,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(readFileSync(insideProbe, 'utf8')).toBe('hook')
|
||||
expect(existsSync(outsideProbe)).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a background denial once the task settles', async () => {
|
||||
const workdir = await tempDir(homedir())
|
||||
const bash = await sandboxedBash(workdir, 'read-only')
|
||||
|
||||
@@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
|
||||
*/
|
||||
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
|
||||
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
|
||||
// infrastructure-failure outcome, then map spawn failures and
|
||||
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
|
||||
// failure with a signal-less kill and a runner failure with an ordinary
|
||||
// wrapper exit; real nonzero command exits must remain `completed`.
|
||||
// infrastructure-failure outcome, then map it to task `failed`. Restricted
|
||||
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
|
||||
// still alias a signal-less kill; real nonzero command exits must remain
|
||||
// `completed`.
|
||||
if (proc.status === 'killed') {
|
||||
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 27fb7b936b796b956f7348fa776856180350bb56
|
||||
README.zh.md: 06dbcc21c31c8ed9fd72d3c07d43c8db1bcca0bb
|
||||
README.md: bbc834db9489941c171aea1cb4e6dadb6f24d211
|
||||
README.zh.md: 065a6b771dbd7eea87f0c632a6dd9f0fde6c0100
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
|
||||
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。
|
||||
|
||||
|
||||
@@ -198,8 +198,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.description,
|
||||
.unlisted {
|
||||
.description {
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
@@ -208,10 +207,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unlisted {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -174,8 +174,13 @@ export function ModelSelect(
|
||||
})
|
||||
}
|
||||
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
|
||||
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
|
||||
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
||||
const triggerAria = currentChoice === undefined
|
||||
? t('trigger.selectAria')
|
||||
: effortLabel === undefined
|
||||
? t('trigger.aria', { model: modelLabel })
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
const itemRef = () => {
|
||||
@@ -189,9 +194,7 @@ export function ModelSelect(
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={effortLabel === undefined
|
||||
? t('trigger.aria', { model: modelLabel })
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
|
||||
aria-label={triggerAria}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
@@ -277,9 +280,6 @@ export function ModelSelect(
|
||||
{model.description !== undefined && (
|
||||
<span className={css.description}>{model.description}</span>
|
||||
)}
|
||||
{model.unlisted === true && (
|
||||
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
{selected ? <IconCheckOutline16 /> : null}
|
||||
|
||||
@@ -51,9 +51,7 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt
|
||||
rows.push({
|
||||
id: rowId(group.id, model.id),
|
||||
label: model.name,
|
||||
detail: model.unlisted === true
|
||||
? t('option.unlisted', { group: group.name })
|
||||
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
|
||||
detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
|
||||
...(directory.current.provider === group.id && directory.current.model === model.id
|
||||
? { active: true } : {}),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/** `model` namespace dictionaries. */
|
||||
/**
|
||||
* `model` namespace dictionaries.
|
||||
*
|
||||
* `trigger.selectAria` reads identically to `trigger.fallback` today and is
|
||||
* still a separate key: the visible fallback label and the accessible name of
|
||||
* an unset trigger are free to diverge per locale, and folding it into
|
||||
* `trigger.aria` would announce the degenerate "Select model, current Select
|
||||
* model".
|
||||
*/
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'command.description': '选择本会话使用的模型',
|
||||
'option.unlisted': '{group} · 未列入目录',
|
||||
'option.loadError': '目录加载失败:{message}',
|
||||
'trigger.fallback': '选择模型',
|
||||
'trigger.selectAria': '选择模型',
|
||||
'trigger.aria': '选择模型,当前 {model}',
|
||||
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
||||
'menu.aria': '模型与推理等级',
|
||||
@@ -16,7 +24,6 @@ export const zh = {
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'option.currentUnlisted': '当前模型 · 未列入目录',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
@@ -27,9 +34,9 @@ export type ModelKey = keyof typeof zh
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'command.description': 'Select the model for this conversation',
|
||||
'option.unlisted': '{group} · Not in catalog',
|
||||
'option.loadError': 'Catalog failed to load: {message}',
|
||||
'trigger.fallback': 'Select model',
|
||||
'trigger.selectAria': 'Select model',
|
||||
'trigger.aria': 'Select model, current {model}',
|
||||
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
||||
'menu.aria': 'Model and reasoning effort',
|
||||
@@ -40,7 +47,6 @@ export const en = {
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'option.currentUnlisted': 'Current model · Not in catalog',
|
||||
'empty.models': 'No models available.',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
|
||||
@@ -111,6 +111,29 @@ describe('ModelSelect reasoning effort', () => {
|
||||
.toEqual(['Default', 'Standard'])
|
||||
})
|
||||
|
||||
it('prompts for a new selection when the current target is no longer advertised', () => {
|
||||
const directory = createSnapshotStore(state({
|
||||
current: { provider: 'deepseek-official', model: 'removed-model' },
|
||||
}))
|
||||
const select = vi.fn().mockResolvedValue(true)
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '选择模型' })
|
||||
expect(trigger.textContent).toContain('选择模型')
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
|
||||
expect(screen.queryByText('removed-model')).toBeNull()
|
||||
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders no Agent-bound control for an addressed subagent session', () => {
|
||||
const load = vi.fn()
|
||||
render(<ModelSelect
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
|
||||
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
|
||||
README.zh.md: a467b65da0bf0a38951cd11dba9ec54a2b03c08e
|
||||
README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89
|
||||
README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957
|
||||
|
||||
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -20,7 +20,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
364
packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx
Normal file
364
packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Curated editor for the direct DeepSeek adapter's advisory model catalog.
|
||||
* The settings layer replaces `models` as one array, so the parent supplies
|
||||
* the effective inherited rows until the first edit materializes a user
|
||||
* override; reset removes that override instead of copying defaults into it.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** One catalog entry kept structurally open so hidden or future fields survive an edit. */
|
||||
export type DeepSeekModelDraft = Record<string, unknown>
|
||||
|
||||
/** The catalog fields this editor writes. */
|
||||
type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens'
|
||||
|
||||
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
|
||||
type CapacityField = 'contextWindow' | 'maxTokens'
|
||||
|
||||
/** Row index encoded in an editing-buffer key. */
|
||||
function rowOf(key: string): number {
|
||||
return Number(key.slice(0, key.indexOf(':')))
|
||||
}
|
||||
|
||||
/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */
|
||||
const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i
|
||||
|
||||
/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */
|
||||
const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const
|
||||
|
||||
/**
|
||||
* Read a typed capacity, so a user can write `256K` or `1M` instead of counting
|
||||
* zeroes. The stored value stays a plain token count.
|
||||
* @param text - raw field text.
|
||||
* @returns the count; `undefined` when blank (inherit), `NaN` when unreadable
|
||||
* (rejected by {@link validateDeepSeekModels} before any write).
|
||||
*/
|
||||
export function parseCapacity(text: string): number | undefined {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) return undefined
|
||||
const match = CAPACITY_PATTERN.exec(trimmed)
|
||||
if (match === null) return Number.NaN
|
||||
const suffix = match[2]?.toLowerCase()
|
||||
const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1
|
||||
const scaled = Number(match[1]) * scale
|
||||
// A decimal multiple is exact in intent but not in binary floating point
|
||||
// (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back.
|
||||
const rounded = Math.round(scaled)
|
||||
return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled
|
||||
}
|
||||
|
||||
/**
|
||||
* Spell a stored count back in the shortest form that survives a round trip
|
||||
* through {@link parseCapacity}; a count that is not a whole number of
|
||||
* thousands stays written out.
|
||||
* @param value - stored capacity.
|
||||
* @returns the field text.
|
||||
*/
|
||||
export function formatCapacity(value: number): string {
|
||||
if (!Number.isInteger(value) || value <= 0) return String(value)
|
||||
if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M`
|
||||
if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** A localized validation failure for one user-owned model array. */
|
||||
export interface DeepSeekModelsValidationFailure {
|
||||
/** Zero-based model position. */
|
||||
index: number
|
||||
/** Message key owned by the Models settings section. */
|
||||
key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid'
|
||||
| 'modelMaxTokensInvalid'
|
||||
}
|
||||
|
||||
/** Convert a schema-validated catalog value into records without dropping hidden fields. */
|
||||
export function modelDrafts(value: unknown): DeepSeekModelDraft[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map(entry =>
|
||||
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
||||
? entry as DeepSeekModelDraft
|
||||
: {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate adapter constraints that the serialized schema cannot express.
|
||||
* @param value - user-owned `models` value, or undefined while inherited.
|
||||
* @returns the first invalid row, or undefined when the adapter will accept it.
|
||||
*/
|
||||
export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const models = modelDrafts(value)
|
||||
const seen = new Set<string>()
|
||||
for (const [index, model] of models.entries()) {
|
||||
// Compared trimmed: surrounding whitespace is a paste artifact the adapter
|
||||
// would never match, and an untrimmed compare lets `model ` slip past the
|
||||
// duplicate check against its own twin.
|
||||
const id = model['id']
|
||||
const trimmed = typeof id === 'string' ? id.trim() : undefined
|
||||
if (trimmed === undefined || trimmed.length === 0) return { index, key: 'modelIdRequired' }
|
||||
if (seen.has(trimmed)) return { index, key: 'modelIdDuplicate' }
|
||||
seen.add(trimmed)
|
||||
const name = model['name']
|
||||
if (name !== undefined && (typeof name !== 'string' || name.length === 0)) {
|
||||
return { index, key: 'modelNameInvalid' }
|
||||
}
|
||||
const contextWindow = model['contextWindow']
|
||||
if (contextWindow !== undefined
|
||||
&& (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) {
|
||||
return { index, key: 'modelContextInvalid' }
|
||||
}
|
||||
const maxTokens = model['maxTokens']
|
||||
if (maxTokens !== undefined
|
||||
&& (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) {
|
||||
return { index, key: 'modelMaxTokensInvalid' }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Props of {@link DeepSeekModelsEditor}. */
|
||||
export interface DeepSeekModelsEditorProps {
|
||||
/** Effective rows: inherited until the parent materializes an override. */
|
||||
models: readonly DeepSeekModelDraft[]
|
||||
/** Whether the user layer currently owns the whole array. */
|
||||
overridden: boolean
|
||||
/** Fallback context capacity used when a row omits its exact value. */
|
||||
defaultContextWindow: number | undefined
|
||||
/** Fallback output cap used when a row omits its exact value. */
|
||||
defaultMaxTokens: number | undefined
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable every mutation. */
|
||||
disabled: boolean
|
||||
/** Replace the user-owned array after one visible edit. */
|
||||
onChange: (models: DeepSeekModelDraft[]) => void
|
||||
/** Remove the user-owned array and return to inheritance. */
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the direct DeepSeek adapter's model catalog: id and display name on
|
||||
* each row, capacities behind the row's own disclosure.
|
||||
* @param props - effective rows plus the array-level override actions.
|
||||
* @returns the catalog editor.
|
||||
*/
|
||||
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
|
||||
// Capacities are edited as text, so a field's keystrokes are held here
|
||||
// rather than re-derived from the parsed count on every change, which would
|
||||
// rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the
|
||||
// save-time rejection names a row the user can still see — which is why
|
||||
// this is one entry PER FIELD: a single active buffer would be displaced by
|
||||
// editing any other field, and the abandoned one would fall back to
|
||||
// rendering its stored NaN as the literal `NaN`.
|
||||
//
|
||||
// Keys carry the row index, so the two operations that move indexes maintain
|
||||
// them: `remove` re-keys around the dropped row, and reset clears them all
|
||||
// because the rows they annotated are gone.
|
||||
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(() => new Map())
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
|
||||
|
||||
const update = (index: number, key: CatalogField, value: unknown): void => {
|
||||
const next = props.models.map((model, at) => {
|
||||
const copy = { ...model }
|
||||
if (at !== index) return copy
|
||||
if (value === undefined) Reflect.deleteProperty(copy, key)
|
||||
else copy[key] = value
|
||||
return copy
|
||||
})
|
||||
props.onChange(next)
|
||||
}
|
||||
|
||||
const remove = (index: number): void => {
|
||||
setEditing((current) => {
|
||||
const next = new Map<string, string>()
|
||||
for (const [key, text] of current) {
|
||||
const at = rowOf(key)
|
||||
if (at === index) continue
|
||||
// Only the row number moves; the field half of the key is untouched.
|
||||
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setExpanded((current) => {
|
||||
const next = new Set<number>()
|
||||
for (const at of current) {
|
||||
if (at === index) continue
|
||||
next.add(at > index ? at - 1 : at)
|
||||
}
|
||||
return next
|
||||
})
|
||||
props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model })))
|
||||
}
|
||||
|
||||
const reset = (): void => {
|
||||
setEditing(new Map())
|
||||
setExpanded(new Set())
|
||||
props.onReset()
|
||||
}
|
||||
|
||||
const toggle = (index: number): void => {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current)
|
||||
if (!next.delete(index)) next.add(index)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** The field's text: its live keystrokes, else the stored count spelled short. */
|
||||
const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => {
|
||||
const typed = editing.get(`${String(index)}:${field}`)
|
||||
if (typed !== undefined) return typed
|
||||
const value = model[field]
|
||||
return typeof value === 'number' ? formatCapacity(value) : ''
|
||||
}
|
||||
|
||||
const settleCapacity = (index: number, field: CapacityField): void => {
|
||||
const key = `${String(index)}:${field}`
|
||||
const typed = editing.get(key)
|
||||
if (typed === undefined) return
|
||||
// Unreadable text stays on screen: the save-time rejection names a row the
|
||||
// user can still see and correct.
|
||||
const parsed = parseCapacity(typed)
|
||||
if (parsed !== undefined && Number.isNaN(parsed)) return
|
||||
setEditing((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** One capacity field of one row, rendered inside the row's disclosure. */
|
||||
const capacityField = (
|
||||
model: DeepSeekModelDraft,
|
||||
index: number,
|
||||
field: CapacityField,
|
||||
fallback: number | undefined,
|
||||
): ReactNode => (
|
||||
<label className={styles['modelField']}>
|
||||
<span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={capacityText(model, index, field)}
|
||||
placeholder={fallback === undefined
|
||||
? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
|
||||
: formatCapacity(fallback)}
|
||||
aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value
|
||||
setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
|
||||
update(index, field, parseCapacity(text))
|
||||
}}
|
||||
onBlur={() => { settleCapacity(index, field) }}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
|
||||
<div className={styles['modelListHead']}>
|
||||
<div className={styles['modelCatalogHeading']}>
|
||||
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
|
||||
<span className={styles['modelCatalogMeta']}>
|
||||
{props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')}
|
||||
</span>
|
||||
</div>
|
||||
{props.overridden
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['linkButton']}
|
||||
disabled={props.disabled}
|
||||
onClick={reset}
|
||||
>
|
||||
{props.t('resetModels')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{props.models.length === 0
|
||||
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
|
||||
: (
|
||||
<div className={styles['modelList']}>
|
||||
{props.models.map((model, index) => (
|
||||
<div className={styles['modelEntry']} key={index}>
|
||||
<div className={styles['modelRow']}>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={typeof model['id'] === 'string' ? model['id'] : ''}
|
||||
placeholder={props.t('modelId')}
|
||||
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => { update(index, 'id', event.target.value) }}
|
||||
onBlur={(event) => {
|
||||
// Settle a pasted id rather than trimming per keystroke,
|
||||
// which would stop the user typing an interior space.
|
||||
const trimmed = event.target.value.trim()
|
||||
if (trimmed !== event.target.value) update(index, 'id', trimmed)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={typeof model['name'] === 'string' ? model['name'] : ''}
|
||||
placeholder={props.t('modelName')}
|
||||
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
|
||||
disabled={props.disabled}
|
||||
onChange={(event) => {
|
||||
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['iconButton']}
|
||||
aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`}
|
||||
aria-expanded={expanded.has(index)}
|
||||
title={props.t('modelAdvanced')}
|
||||
onClick={() => { toggle(index) }}
|
||||
>
|
||||
{expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
|
||||
aria-label={`${props.t('removeModel')} ${String(index + 1)}`}
|
||||
title={props.t('removeModel')}
|
||||
disabled={props.disabled}
|
||||
onClick={() => { remove(index) }}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{expanded.has(index)
|
||||
? (
|
||||
<div className={styles['modelAdvanced']}>
|
||||
{capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
|
||||
{capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addModelButton']}
|
||||
disabled={props.disabled}
|
||||
onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{props.t('addModel')}
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
/* Models settings section, in the settings-panel design language: 14/22 body,
|
||||
* 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense),
|
||||
* 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and
|
||||
* the Button/Input primitives already use.
|
||||
*
|
||||
* Every color resolves through a `--dsw-alias-*` token. The section used to
|
||||
* name `--border` / `--surface` / `--text-*`, which nothing in this app
|
||||
* defines, so it always rendered the light-mode literals written as their
|
||||
* fallbacks and stayed light under the dark theme. */
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8,19 +18,23 @@
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
@@ -31,9 +45,11 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* A configured provider: outlined on the panel fill, so the filled editor
|
||||
card it expands into reads as the nested object. */
|
||||
.rowCard {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
@@ -41,7 +57,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
@@ -51,38 +66,59 @@
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
/* `box-sizing` on every control here: the app has no global border-box reset,
|
||||
so without it the outlined variants stand 2px taller than the filled ones
|
||||
they sit beside (Cancel next to Apply, Edit next to Delete). */
|
||||
.primaryButton,
|
||||
.secondaryButton,
|
||||
.addButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 18px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
.secondaryButton,
|
||||
.addButton {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled),
|
||||
.addButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
@@ -90,12 +126,19 @@
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border-radius: 18px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -103,17 +146,43 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
|
||||
/* Provider-row controls take the dense capsule (Button `.sm`). */
|
||||
.rowActions .secondaryButton,
|
||||
.rowActions .dangerButton {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.primaryButton:disabled,
|
||||
.secondaryButton:disabled,
|
||||
.dangerButton:disabled {
|
||||
opacity: 0.5;
|
||||
.dangerButton:disabled,
|
||||
.addButton:disabled,
|
||||
.linkButton:disabled,
|
||||
.addModelButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.primaryButton:focus-visible,
|
||||
.secondaryButton:focus-visible,
|
||||
.dangerButton:focus-visible,
|
||||
.addButton:focus-visible,
|
||||
.linkButton:focus-visible,
|
||||
.addModelButton:focus-visible,
|
||||
.iconButton:focus-visible,
|
||||
.customizedSummary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* Editing surface: a filled module on the panel, matching the settings
|
||||
selector fill rather than adding another outline inside the row. */
|
||||
.editor {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -128,11 +197,14 @@
|
||||
|
||||
.editorTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.editorRoute {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -147,33 +219,36 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.linkButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.advancedHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -194,29 +269,12 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.addButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.addCard,
|
||||
.setupCard {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -224,9 +282,9 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Nested in a card that already carries the module chrome. */
|
||||
.addCard .editor,
|
||||
.setupCard .editor {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -236,12 +294,44 @@
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Native disclosure marker replaced by a rotating chevron: the built-in
|
||||
triangle differs per engine and cannot take the label color. */
|
||||
.customizedSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
padding: 2px 4px;
|
||||
margin-left: -4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
list-style: revert;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.customizedSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.customizedSummary::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-right: 1.5px solid currentcolor;
|
||||
border-bottom: 1.5px solid currentcolor;
|
||||
transform: rotate(-45deg) translate(-1px, -1px);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.customized[open] > .customizedSummary::before {
|
||||
transform: rotate(45deg) translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.customizedSummary:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.customizedBody {
|
||||
@@ -251,17 +341,173 @@
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Model catalog: a table, not a stack of cards. The column captions are
|
||||
written once above the rows, so a row is one line of fields plus its
|
||||
delete control; each field still carries the indexed `aria-label` that
|
||||
names it, and the caption strip is hidden from assistive tech to keep
|
||||
that name from being announced twice. */
|
||||
.modelCatalog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.modelCatalogHeading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modelCatalogTitle {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.modelCatalogMeta,
|
||||
.modelEmpty {
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Model list, shared with the pi-ai provider form (PR #1368): one bordered
|
||||
entry per model, id and display name on the row, capacities behind the
|
||||
row's own disclosure. The token names are this file's, not that branch's —
|
||||
`--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
|
||||
`--dsw-alias-text-primary` are undefined here and resolve to their
|
||||
light-mode literals, which is the defect this section was just moved off. */
|
||||
.modelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modelListHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modelEntry {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.modelRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Square, label-free affordances: the row's own inputs carry the meaning, so
|
||||
the actions stay glyphs and announce themselves through aria-label. */
|
||||
.iconButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* The delete glyph keeps the danger tint the rest of the section uses. */
|
||||
.iconButtonDanger:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.modelAdvanced {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 8px 4px 2px;
|
||||
}
|
||||
|
||||
.modelField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modelFieldLabel {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.modelEmpty {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.addModelButton {
|
||||
box-sizing: border-box;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addModelButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Enum pickers hold a handful of short options; a field-width dropdown reads
|
||||
as a text field the user is expected to fill. */
|
||||
select.input {
|
||||
max-width: 240px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
@@ -271,6 +517,11 @@
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Select variant of .input: replaces the OS arrow (which sits flush against
|
||||
the right edge) with the shared 12px chevron inset like the composer's
|
||||
.select chips; the right pad reserves its cell. */
|
||||
@@ -288,6 +539,7 @@
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@@ -303,3 +555,20 @@
|
||||
.deleteConfirm:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
|
||||
/* Icon-button label seat: named for assistive tech and for the tests that
|
||||
query these controls by their text. */
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.customizedSummary::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,23 @@
|
||||
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
|
||||
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
|
||||
* minimal `settings.mutate` path ops against the stored section — the card
|
||||
* reads the redacted descriptor, so it names only the fields it can see and a
|
||||
* stored literal secret is never collaterally removed.
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
|
||||
* path ops against the stored section — the card reads the redacted
|
||||
* descriptor, so it names only the fields it can see and a stored literal
|
||||
* secret is never collaterally removed.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import {
|
||||
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
|
||||
} from './DeepSeekModelsEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
@@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
if (layout === 'deepseek') {
|
||||
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
|
||||
if (modelFailure !== undefined) {
|
||||
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined && settingsPath.length === 0) {
|
||||
const sectionError = validateDraft(node, next)
|
||||
@@ -229,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
|
||||
const keyLocked = keyState?.writable === false
|
||||
|
||||
/**
|
||||
* The catalog beneath the user layer: what the composition entry pinned, or
|
||||
* else the schema default that `resolve` would supply. The effective value
|
||||
* cannot answer this — it still carries the stored override until the unset
|
||||
* is applied, so reading it would echo that override straight back the
|
||||
* moment reset drops it, leaving the rows unchanged until a reload.
|
||||
*/
|
||||
const inheritedModels = (): unknown => {
|
||||
const pinned = getPath(namespace.base, [...settingsPath, 'models'])
|
||||
return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated fields of one known adapter family. Taking the narrowed
|
||||
* family as a parameter is what makes `EFFORT_FIELD` total here: an
|
||||
@@ -236,6 +258,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
||||
const effortField = EFFORT_FIELD[family]
|
||||
const customModels = getPath(draft, ['models'])
|
||||
const modelsOverridden = hasPath(draft, ['models'])
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
return (
|
||||
<>
|
||||
<div className={styles['field']}>
|
||||
@@ -289,6 +316,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{family === 'deepseek'
|
||||
? (
|
||||
<DeepSeekModelsEditor
|
||||
models={models}
|
||||
overridden={modelsOverridden}
|
||||
defaultContextWindow={typeof defaultContextWindow === 'number'
|
||||
? defaultContextWindow
|
||||
: undefined}
|
||||
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
|
||||
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,27 @@ export const en = {
|
||||
baseUrlDefault: 'Provider default',
|
||||
effort: 'Reasoning effort',
|
||||
effortInherit: 'Default',
|
||||
models: 'Models',
|
||||
modelsInherited: 'Using the adapter defaults',
|
||||
modelsCustomized: 'Customized model catalog',
|
||||
resetModels: 'Restore defaults',
|
||||
model: 'Model',
|
||||
modelId: 'Model ID',
|
||||
modelName: 'Display name',
|
||||
modelNamePlaceholder: 'Uses the model ID when empty',
|
||||
contextWindow: 'Context window',
|
||||
contextWindowPlaceholder: 'Uses the provider default',
|
||||
maxTokens: 'Max output tokens',
|
||||
maxTokensPlaceholder: 'Uses the provider default',
|
||||
modelAdvanced: 'Capacities',
|
||||
addModel: 'Add model',
|
||||
removeModel: 'Delete model',
|
||||
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
|
||||
modelIdRequired: 'Model ID is required.',
|
||||
modelIdDuplicate: 'Model ID must be unique.',
|
||||
modelNameInvalid: 'Display name cannot be empty.',
|
||||
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
|
||||
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
|
||||
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
|
||||
onboardingTitle: 'Add an API key to get started',
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
@@ -70,6 +91,27 @@ export const zh: typeof en = {
|
||||
baseUrlDefault: '提供方默认',
|
||||
effort: '推理强度',
|
||||
effortInherit: '默认',
|
||||
models: '模型目录',
|
||||
modelsInherited: '正在使用适配器默认模型',
|
||||
modelsCustomized: '已自定义模型目录',
|
||||
resetModels: '恢复默认模型',
|
||||
model: '模型',
|
||||
modelId: '模型 ID',
|
||||
modelName: '显示名称',
|
||||
modelNamePlaceholder: '留空时使用模型 ID',
|
||||
contextWindow: '上下文窗口',
|
||||
contextWindowPlaceholder: '使用提供方默认值',
|
||||
maxTokens: '最大输出 token 数',
|
||||
maxTokensPlaceholder: '使用提供方默认值',
|
||||
modelAdvanced: '容量',
|
||||
addModel: '添加模型',
|
||||
removeModel: '删除模型',
|
||||
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
|
||||
modelIdRequired: '模型 ID 不能为空。',
|
||||
modelIdDuplicate: '模型 ID 不能重复。',
|
||||
modelNameInvalid: '显示名称不能为空。',
|
||||
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
|
||||
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
|
||||
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
|
||||
onboardingTitle: '添加一个 API Key 开始使用',
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import {
|
||||
DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
|
||||
} from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import type { ProviderRow } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
@@ -16,6 +19,16 @@ afterEach(cleanup)
|
||||
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
|
||||
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
||||
function expandRow(position: number): void {
|
||||
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`))
|
||||
}
|
||||
|
||||
/** The capacity inputs of every open row, in row order. */
|
||||
function capacityInputs(label: string): HTMLInputElement[] {
|
||||
return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label))
|
||||
}
|
||||
|
||||
const PiAiConfig = Schema.object({
|
||||
token: Schema.string().role('secret'),
|
||||
providers: Schema.dict(Schema.object({
|
||||
@@ -32,15 +45,54 @@ const DeepSeekConfig = Schema.object({
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string().pattern(/^https:\/\//),
|
||||
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
||||
defaultContextWindow: Schema.number().step(1).min(1),
|
||||
models: Schema.array(Schema.object({
|
||||
id: Schema.string().required(),
|
||||
name: Schema.string(),
|
||||
description: Schema.string(),
|
||||
contextWindow: Schema.number().step(1).min(1),
|
||||
// The adapter declares its catalog as a schema default rather than a
|
||||
// composition entry, which is what the restore-defaults path has to read.
|
||||
})).default([
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
description: '',
|
||||
contextWindow: 1_000_000,
|
||||
},
|
||||
{
|
||||
id: 'deepseek-v4-pro',
|
||||
name: 'DeepSeek-V4-Pro',
|
||||
description: '',
|
||||
contextWindow: 1_000_000,
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const DEFAULT_DEEPSEEK_MODELS = [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek-V4-Flash',
|
||||
description: 'Preserved hidden detail',
|
||||
contextWindow: 1_000_000,
|
||||
},
|
||||
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
|
||||
]
|
||||
|
||||
function wireNamespaces(): SettingsNamespaceView[] {
|
||||
return [
|
||||
{
|
||||
ns: 'llm-deepseek',
|
||||
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
|
||||
base: {},
|
||||
value: {
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
baseURL: 'https://base',
|
||||
reasoningEffort: 'high',
|
||||
defaultContextWindow: 1_000_000,
|
||||
maxTokens: 256_000,
|
||||
models: DEFAULT_DEEPSEEK_MODELS,
|
||||
},
|
||||
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
|
||||
user: { reasoningEffort: 'high' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
@@ -244,6 +296,388 @@ describe('ModelsSection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
||||
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
||||
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
|
||||
|
||||
fireEvent.click(screen.getByText(en.addModel))
|
||||
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
||||
const names = screen.getAllByLabelText(new RegExp(en.modelName))
|
||||
expandRow(3)
|
||||
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
|
||||
fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
|
||||
// Only row 3 is open, so its capacity is addressed by its own label.
|
||||
fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{
|
||||
op: 'set',
|
||||
path: ['models'],
|
||||
value: [
|
||||
...DEFAULT_DEEPSEEK_MODELS,
|
||||
{ id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
|
||||
],
|
||||
}],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate DeepSeek model ids before writing', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.click(screen.getByText(en.addModel))
|
||||
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
|
||||
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates every adapter-owned model catalog invariant', () => {
|
||||
expect(modelDrafts(undefined)).toEqual([])
|
||||
expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
|
||||
expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
|
||||
expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
|
||||
.toEqual({ index: 1, key: 'modelIdDuplicate' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
|
||||
.toEqual({ index: 0, key: 'modelNameInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
|
||||
.toEqual({ index: 0, key: 'modelContextInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
|
||||
.toEqual({ index: 0, key: 'modelContextInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
|
||||
.toEqual({ index: 0, key: 'modelContextInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
|
||||
expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
|
||||
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
|
||||
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
|
||||
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
|
||||
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads context windows written as counts, thousands, or millions', () => {
|
||||
expect(parseCapacity('')).toBeUndefined()
|
||||
expect(parseCapacity(' ')).toBeUndefined()
|
||||
expect(parseCapacity('131072')).toBe(131_072)
|
||||
expect(parseCapacity(' 256K ')).toBe(256_000)
|
||||
expect(parseCapacity('256k')).toBe(256_000)
|
||||
expect(parseCapacity('1M')).toBe(1_000_000)
|
||||
expect(parseCapacity('1m')).toBe(1_000_000)
|
||||
// 1M is 1000K, not 1024K: capacities are quoted in decimal.
|
||||
expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
|
||||
// 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
|
||||
// intent must not become a fractional count the validator rejects.
|
||||
expect(parseCapacity('2.3M')).toBe(2_300_000)
|
||||
expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
|
||||
// A genuinely fractional count survives as one, for the validator to reject.
|
||||
expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
|
||||
expect(parseCapacity('abc')).toBeNaN()
|
||||
expect(parseCapacity('1G')).toBeNaN()
|
||||
expect(parseCapacity('1M1')).toBeNaN()
|
||||
})
|
||||
|
||||
it('spells a stored count in the shortest form that round-trips', () => {
|
||||
expect(formatCapacity(1_000_000)).toBe('1M')
|
||||
expect(formatCapacity(256_000)).toBe('256K')
|
||||
expect(formatCapacity(1_500_000)).toBe('1500K')
|
||||
expect(formatCapacity(131_072)).toBe('131072')
|
||||
// Values the validator will reject are shown as-is rather than dressed up.
|
||||
expect(formatCapacity(Number.NaN)).toBe('NaN')
|
||||
expect(formatCapacity(0)).toBe('0')
|
||||
for (const text of ['1M', '256K', '131072', '1500K']) {
|
||||
expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a suffixed context window and stores the plain count', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
const windows = capacityInputs(en.contextWindow)
|
||||
// The inherited 1000000 reads back short.
|
||||
expect((windows[0] as HTMLInputElement).value).toBe('1M')
|
||||
|
||||
// Keystrokes stay verbatim while the row has focus, so typing `1000` does
|
||||
// not rewrite itself to `1K` mid-word.
|
||||
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
|
||||
expect((windows[0] as HTMLInputElement).value).toBe('1000')
|
||||
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
|
||||
expect((windows[0] as HTMLInputElement).value).toBe('1000K')
|
||||
// Blur settles the row to the canonical spelling of the same count.
|
||||
fireEvent.blur(windows[0] as HTMLInputElement)
|
||||
expect((windows[0] as HTMLInputElement).value).toBe('1M')
|
||||
|
||||
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
|
||||
fireEvent.blur(windows[1] as HTMLInputElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{
|
||||
op: 'set',
|
||||
path: ['models'],
|
||||
value: [
|
||||
{ ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
|
||||
{ ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
|
||||
],
|
||||
}],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unreadable context-window text on screen and refuses the write', async () => {
|
||||
const { mutate } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
const windows = capacityInputs(en.contextWindow)
|
||||
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
|
||||
// Blurring a row that is not the edited one leaves the buffer alone.
|
||||
fireEvent.blur(windows[1] as HTMLInputElement)
|
||||
fireEvent.blur(windows[0] as HTMLInputElement)
|
||||
// The text the user typed is still there to correct.
|
||||
expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
|
||||
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the schema default', undefined],
|
||||
['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
|
||||
])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
|
||||
// The regression: reset read the EFFECTIVE value, which still carries the
|
||||
// stored override until the unset is applied — so the rows did not change
|
||||
// and the catalog only looked restored after reopening the card.
|
||||
const { face } = scriptedFace()
|
||||
const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
|
||||
const overridden: SettingsNamespaceView = {
|
||||
ns: 'llm-deepseek',
|
||||
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
||||
value: { ...stored, defaultContextWindow: 1_000_000 },
|
||||
...base === undefined ? {} : { base },
|
||||
user: stored,
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
||||
render(<ProviderEditor
|
||||
provider="deepseek-official"
|
||||
displayName="DeepSeek"
|
||||
namespace={overridden}
|
||||
settingsPath={[]}
|
||||
api={face as never}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
|
||||
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
||||
.toEqual(['user-only-model'])
|
||||
|
||||
fireEvent.click(screen.getByText(en.resetModels))
|
||||
|
||||
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
||||
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
|
||||
.toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
|
||||
})
|
||||
|
||||
it('keeps every row\'s unreadable text, not just the last one edited', async () => {
|
||||
// The regression: one active buffer meant editing a second row displaced
|
||||
// the first, which then fell back to rendering its stored NaN as `NaN` —
|
||||
// losing the text the user was told they could still correct.
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
const windows = capacityInputs(en.contextWindow)
|
||||
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
|
||||
fireEvent.blur(windows[0] as HTMLInputElement)
|
||||
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
|
||||
|
||||
expect((windows[0] as HTMLInputElement).value).toBe('not a number')
|
||||
expect((windows[1] as HTMLInputElement).value).toBe('2M')
|
||||
})
|
||||
|
||||
it('re-keys the typed text around a removed row', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
|
||||
const removeRow = (at: number): void => {
|
||||
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
|
||||
}
|
||||
// Three rows, with text parked on the outer two.
|
||||
fireEvent.click(screen.getByText(en.addModel))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
expandRow(3)
|
||||
fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
|
||||
fireEvent.blur(windows()[0] as HTMLInputElement)
|
||||
fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
|
||||
fireEvent.blur(windows()[2] as HTMLInputElement)
|
||||
|
||||
// Dropping the middle row leaves the row above untouched and carries the
|
||||
// row below down with its own text, rather than stranding it.
|
||||
removeRow(1)
|
||||
expect(windows()).toHaveLength(2)
|
||||
expect((windows()[0] as HTMLInputElement).value).toBe('top text')
|
||||
expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
|
||||
|
||||
// Dropping a row that holds text takes that text with it; the survivor
|
||||
// keeps its own rather than inheriting the deleted row's.
|
||||
removeRow(0)
|
||||
expect(windows()).toHaveLength(1)
|
||||
expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
|
||||
})
|
||||
|
||||
it('drops the typed text when reset replaces the rows it annotated', async () => {
|
||||
// The regression: reset removed the override but left the buffer, so an
|
||||
// inherited row displayed text no settings layer stores — and because an
|
||||
// unreadable buffer never settles, it stayed there indefinitely.
|
||||
const { mutate } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
const windows = capacityInputs(en.contextWindow)
|
||||
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
|
||||
fireEvent.blur(windows[0] as HTMLInputElement)
|
||||
fireEvent.click(screen.getByText(en.resetModels))
|
||||
|
||||
// Reset collapses every row, so the restored capacity needs opening again.
|
||||
expandRow(1)
|
||||
const restored = capacityInputs(en.contextWindow)
|
||||
expect((restored[0] as HTMLInputElement).value).toBe('1M')
|
||||
|
||||
// Reset put the draft back where it started, so Apply writes nothing at
|
||||
// all rather than persisting whatever the stale text had parsed to.
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edits an output cap per model and carries its text across a removal', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expandRow(1)
|
||||
expandRow(2)
|
||||
// The profile's own cap is the placeholder both rows inherit.
|
||||
expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
|
||||
fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
|
||||
|
||||
// Dropping the row above carries the cap text down with its own row.
|
||||
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
|
||||
// The disclosure closes on a second press.
|
||||
expandRow(1)
|
||||
expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{
|
||||
op: 'set',
|
||||
path: ['models'],
|
||||
value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
|
||||
}],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a pasted id and refuses whitespace that would never match', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
|
||||
fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
|
||||
fireEvent.blur(ids[0] as HTMLInputElement)
|
||||
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
|
||||
// A settled id needs no second trim.
|
||||
fireEvent.blur(ids[0] as HTMLInputElement)
|
||||
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
|
||||
|
||||
// An id that is only whitespace is as absent as an empty one, and a padded
|
||||
// id no longer slips past the duplicate check against its own twin.
|
||||
expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
|
||||
expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
|
||||
.toEqual({ index: 1, key: 'modelIdDuplicate' })
|
||||
})
|
||||
|
||||
it('renders malformed draft fallbacks without inventing catalog values', () => {
|
||||
render(<DeepSeekModelsEditor
|
||||
models={[{}]}
|
||||
overridden={false}
|
||||
defaultContextWindow={undefined}
|
||||
defaultMaxTokens={undefined}
|
||||
t={t}
|
||||
disabled={true}
|
||||
onChange={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
|
||||
expandRow(1)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
|
||||
.toBe(en.contextWindowPlaceholder)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
|
||||
.toBe(en.maxTokensPlaceholder)
|
||||
})
|
||||
|
||||
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
|
||||
fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
|
||||
expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
|
||||
fireEvent.click(screen.getByText(en.resetModels))
|
||||
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
|
||||
|
||||
const names = screen.getAllByLabelText(new RegExp(en.modelName))
|
||||
expandRow(1)
|
||||
const windows = capacityInputs(en.contextWindow)
|
||||
fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
|
||||
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
ops: [{
|
||||
op: 'set',
|
||||
path: ['models'],
|
||||
value: [
|
||||
{ id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
|
||||
DEFAULT_DEEPSEEK_MODELS[1],
|
||||
],
|
||||
}],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
|
||||
// The data-loss shape: the old path rebuilt the section from the REDACTED
|
||||
// user layer and replaced it wholesale, deleting any stored literal key.
|
||||
|
||||
@@ -3,11 +3,36 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
|
||||
const tokens = readFileSync(
|
||||
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
/** The declarations of one top-level rule, by selector. */
|
||||
function block(selector: string): string {
|
||||
const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
|
||||
if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`)
|
||||
return match[1] ?? ''
|
||||
}
|
||||
|
||||
describe('ModelsSection theme styles', () => {
|
||||
it('uses the shared theme tokens without light-only fallbacks', () => {
|
||||
it('names only theme variables the token sheet defines', () => {
|
||||
// A `--dsw-*` name the sheet never declares is not a near miss: it silently
|
||||
// resolves to whatever literal sits in its fallback slot, which is how this
|
||||
// section stayed light under the dark theme before. Undeclared names have
|
||||
// no fallback at all and inherit, so both spellings must fail here.
|
||||
const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1])
|
||||
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
|
||||
expect(undeclared).toEqual([])
|
||||
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
|
||||
expect(css).toContain('background: var(--dsw-alias-bg-layer-3)')
|
||||
expect(css).toContain('color: var(--dsw-alias-label-primary)')
|
||||
})
|
||||
|
||||
it('separates the row card from the editor it expands into', () => {
|
||||
// `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
|
||||
// under the dark theme, so filling the row with either erases the nested
|
||||
// editor's boundary. The row is outlined; the fill is the editor's alone.
|
||||
expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)')
|
||||
expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)')
|
||||
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1771,7 +1771,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ConfinedArgv',
|
||||
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}',
|
||||
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'ConfinedSandboxMode',
|
||||
@@ -2317,6 +2317,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ResumeAgentOptions',
|
||||
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
|
||||
},
|
||||
{
|
||||
name: 'RunnerFailureRule',
|
||||
declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SandboxEnforcement',
|
||||
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 0b8761f57556d0076f45e2d5ab735acd092cb253
|
||||
README.zh.md: fa14741f79450382d178e9f62fb2cfd4b2c4a4a2
|
||||
README.md: 552c431e9b82d64d7715e80dc539bcdf2e5c6f49
|
||||
README.zh.md: 62086281b50373ac49a59fe9fbb380543d64e660
|
||||
|
||||
@@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the
|
||||
|
||||
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
|
||||
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
|
||||
|
||||
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。
|
||||
|
||||
会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
|
||||
|
||||
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
|
||||
|
||||
|
||||
@@ -133,30 +133,19 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
||||
|
||||
/**
|
||||
* Build the provider/model catalog over every registered route. Shared by the
|
||||
* session-scoped `session.models` (which passes the session's current target
|
||||
* so an unlisted current model still renders selectable) and the host-scoped
|
||||
* `llm.models` (no current). Per-provider failures ride `failures` without
|
||||
* failing the sound groups; groups that advertise nothing are dropped.
|
||||
* session-scoped `session.models` and host-scoped `llm.models`. Catalog
|
||||
* membership stays advisory: an unlisted session target remains valid for
|
||||
* provider dispatch, but is not injected back into the selector after its
|
||||
* owning catalog stops advertising it. Per-provider failures ride `failures`
|
||||
* without failing the sound groups; groups that advertise nothing are dropped.
|
||||
*/
|
||||
async function buildModelCatalog(
|
||||
ctx: Context,
|
||||
current?: { provider: string; model: string },
|
||||
): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> {
|
||||
async function buildModelCatalog(ctx: Context): Promise<{
|
||||
groups: ModelProviderGroup[]
|
||||
failures: ModelCatalogFailure[]
|
||||
}> {
|
||||
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
|
||||
try {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models = [...advertised]
|
||||
if (
|
||||
current !== undefined
|
||||
&& provider.id === current.provider
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({
|
||||
provider: provider.id,
|
||||
id: current.model,
|
||||
name: current.model,
|
||||
})
|
||||
}
|
||||
const models = await ctx.llm.listModels(provider.id)
|
||||
const entries = await Promise.all(models.map(async (model) => {
|
||||
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
|
||||
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
|
||||
@@ -177,12 +166,6 @@ async function buildModelCatalog(
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...current !== undefined
|
||||
&& provider.id === current.provider
|
||||
&& model.id === current.model
|
||||
&& !advertised.some(candidate => candidate.id === current.model)
|
||||
? { unlisted: true as const }
|
||||
: {},
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
@@ -1630,7 +1613,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const current = targetFor(found.agent).current
|
||||
const { groups, failures } = await buildModelCatalog(ctx, current)
|
||||
const { groups, failures } = await buildModelCatalog(ctx)
|
||||
return ok(request, { current: { ...current }, groups, failures })
|
||||
},
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* surfaces. `llm.providers` merges the configurable-provider directory
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog
|
||||
* (`session.models` minus the per-session current/unlisted logic). Both
|
||||
* invalidate on the `host/models-changed` frame.
|
||||
* (the same groups as `session.models`, without the per-session current
|
||||
* target). Both invalidate on the `host/models-changed` frame.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
@@ -165,7 +165,6 @@ export const modelCatalogModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unlisted: z.literal(true).optional(),
|
||||
reasoning: modelReasoningSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
|
||||
|
||||
|
||||
@@ -89,8 +89,6 @@ export interface ModelCatalogModel {
|
||||
name: string
|
||||
/** Optional provider-supplied description. */
|
||||
description?: string
|
||||
/** The current model was inserted because the advisory catalog omitted it. */
|
||||
unlisted?: true
|
||||
/** Exact-route reasoning metadata when the adapter exposes it. */
|
||||
reasoning?: ModelReasoning
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Web session model-directory and selection behavior: dynamic provider grouping,
|
||||
* provider-local catalog failures, logged-target restoration, advisory unlisted
|
||||
* models, and the prompt-assembly boundary for a running selection change.
|
||||
* provider-local catalog failures, logged-target restoration without stale
|
||||
* catalog injection, advisory pass-through models, and the prompt-assembly
|
||||
* boundary for a running selection change.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -118,7 +119,7 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
|
||||
}
|
||||
|
||||
describe('Web session model selection', () => {
|
||||
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
|
||||
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek-official',
|
||||
model: 'private-preview',
|
||||
@@ -143,12 +144,6 @@ describe('Web session model selection', () => {
|
||||
description: 'Reasoning model',
|
||||
reasoning: REASONING,
|
||||
},
|
||||
{
|
||||
id: 'private-preview',
|
||||
name: 'private-preview',
|
||||
unlisted: true,
|
||||
reasoning: REASONING,
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(catalog.failures).toEqual([
|
||||
|
||||
@@ -204,7 +204,6 @@ describe('sessions domain schemas', () => {
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
description: 'fast',
|
||||
unlisted: true,
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
|
||||
@@ -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/llm/llm-deepseek/README.md
|
||||
README.md: 020aa65073495526be3f32912b7cd06667c52a2e
|
||||
README.zh.md: 0b2c9efd5ec9bc08e21be1966e182a703c5ea405
|
||||
README.md: 72b04f5982ec7fdef024835ab23e7a9f0005f84c
|
||||
README.zh.md: 2c9155e0da1373e5d9f8a913c10b11ddee417b1f
|
||||
|
||||
@@ -40,7 +40,7 @@ The plugin registers the single provider route `deepseek-official` together with
|
||||
|
||||
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`.
|
||||
`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`.
|
||||
|
||||
The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
|
||||
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
|
||||
|
||||
`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。
|
||||
`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。
|
||||
|
||||
同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface DeepSeekCatalogModel {
|
||||
description?: string
|
||||
/** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */
|
||||
contextWindow?: number
|
||||
/** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +183,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
? { provider, id: model, name: model }
|
||||
: modelInfo(provider, configured),
|
||||
context: { contextWindow },
|
||||
defaultMaxTokens: connection.maxTokens,
|
||||
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
|
||||
...connection.defaults.thinking === 'disabled'
|
||||
? {
|
||||
reasoning: {
|
||||
|
||||
@@ -68,7 +68,7 @@ export interface Config {
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Default thinking effort (default `high`); `off` disables thinking per request. */
|
||||
reasoningEffort?: 'off' | 'high' | 'max'
|
||||
/** Default per-request output cap (default 256,000); explicit request values win. */
|
||||
/** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */
|
||||
maxTokens?: number
|
||||
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
|
||||
defaultContextWindow?: number
|
||||
@@ -85,6 +85,7 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
contextWindow: z.number().step(1).min(1),
|
||||
maxTokens: z.number().step(1).min(1),
|
||||
})
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -125,6 +126,12 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
`llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`,
|
||||
)
|
||||
}
|
||||
if (model.maxTokens !== undefined
|
||||
&& (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
|
||||
throw new Error(
|
||||
`llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,
|
||||
)
|
||||
}
|
||||
if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
|
||||
seen.add(model.id)
|
||||
return {
|
||||
@@ -132,6 +139,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
|
||||
...model.name === undefined ? {} : { name: model.name },
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
|
||||
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -792,6 +792,26 @@ describe('plugin registration and config', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => {
|
||||
expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] }))
|
||||
.toThrow(/maxTokens must be a positive integer/)
|
||||
})
|
||||
|
||||
it('prefers a model\'s own output cap over the profile default', async () => {
|
||||
// The profile default stays what an unlisted or uncapped model resolves
|
||||
// to, so adding a per-model cap changes one model rather than the route.
|
||||
const adapter = adapterOf({ maxTokens: 4096, models: [
|
||||
{ id: 'capped', maxTokens: 512 },
|
||||
{ id: 'uncapped' },
|
||||
] })
|
||||
await expect(adapter.resolveModel('deepseek-official', 'capped'))
|
||||
.resolves.toMatchObject({ defaultMaxTokens: 512 })
|
||||
await expect(adapter.resolveModel('deepseek-official', 'uncapped'))
|
||||
.resolves.toMatchObject({ defaultMaxTokens: 4096 })
|
||||
await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog'))
|
||||
.resolves.toMatchObject({ defaultMaxTokens: 4096 })
|
||||
})
|
||||
|
||||
it('rejects invalid context capacity when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
||||
|
||||
class EmptySandbox extends SandboxProvider {
|
||||
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class RecordingSandbox extends SandboxProvider {
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class PassthroughSandbox extends SandboxProvider {
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ afterEach(async () => {
|
||||
|
||||
class PassthroughSandbox extends SandboxProvider {
|
||||
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ afterEach(async () => {
|
||||
|
||||
class PassthroughSandbox extends SandboxProvider {
|
||||
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/sandbox/sandbox-local/README.md
|
||||
README.md: 923d983be8c2ccd60ed6eabcf9212dd89ef9bce3
|
||||
README.zh.md: c85fd7e36b8575709ce9341f337da3a8bafd25b0
|
||||
README.md: f7dd5619fa69ae226def78415a91d51ba99e0a65
|
||||
README.zh.md: c8abe05408ac8917b2c5dc39acdbdf9d9539744d
|
||||
|
||||
@@ -6,9 +6,9 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca
|
||||
|
||||
The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal.
|
||||
|
||||
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
|
||||
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
|
||||
|
||||
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
|
||||
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
|
||||
|
||||
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
|
||||
|
||||
@@ -37,4 +37,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
|
||||
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
|
||||
- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
|
||||
- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly.
|
||||
- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
包根目录导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 仍为内部实现。
|
||||
|
||||
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。
|
||||
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。
|
||||
|
||||
策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝和 runner 失败签名。`runnerCommand` 是操作方对 bwrap 形式 runner 的断言,会跳过探测;但命令缺失或不可执行时,执行仍会被拒绝。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。
|
||||
策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。
|
||||
|
||||
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
|
||||
|
||||
@@ -37,4 +37,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list
|
||||
- **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
|
||||
- **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
|
||||
- **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。
|
||||
- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile。
|
||||
- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* Local sandbox backend. It selects the platform runner chain (Linux bwrap then
|
||||
* Landlock; macOS Seatbelt), functionally probes competing candidates once, and
|
||||
* reports each wrap's enforcement and stderr dialects. Missing or unusable
|
||||
* reports each wrap's enforcement and stderr classification facts. Missing or unusable
|
||||
* confinement fails closed rather than returning the original argv.
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
|
||||
import {
|
||||
LAUNCHER_BIN,
|
||||
LAUNCHER_FAILURE_EXIT,
|
||||
launcherPath as landlockLauncherPath,
|
||||
probe as defaultProbeLandlock,
|
||||
} from 'node-addon-landlock-run'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -20,17 +25,18 @@ export interface Config {
|
||||
/**
|
||||
* Override the runner argv; bwrap-shaped profile arguments are appended. A
|
||||
* non-empty override asserts full enforcement and skips built-in selection and
|
||||
* probing; a broken runner then fails at execution and must be identifiable by
|
||||
* {@link runnerFailureSignatures}.
|
||||
* probing. A runner that starts but refuses its profile must be identifiable by
|
||||
* {@link runnerFailureSignatures}. Consumers classify spawn rejection; only
|
||||
* attributable `ENOENT` or `EACCES` with runner argv[0] provenance becomes an
|
||||
* infrastructure failure.
|
||||
*/
|
||||
runnerCommand?: string[]
|
||||
/**
|
||||
* Case-insensitive stderr substrings emitted when a configured
|
||||
* {@link runnerCommand} refuses its profile before executing the wrapped
|
||||
* command. Required and non-empty with `runnerCommand`; rejected without
|
||||
* it. Missing/unexecutable runner errors are added automatically from
|
||||
* `runnerCommand[0]`, while these signatures cover an executable runner's
|
||||
* own failure dialect.
|
||||
* it. Each entry is a non-empty, single-line, case-insensitive substring
|
||||
* covering the executable runner's own failure dialect.
|
||||
*/
|
||||
runnerFailureSignatures?: string[]
|
||||
/** Positive timeout for each functional probe; zero would mean unbounded to Node. */
|
||||
@@ -142,15 +148,22 @@ const DENIAL_SIGNATURES = {
|
||||
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
|
||||
|
||||
/**
|
||||
* Runner-owned stderr prefixes cover both internal refusal and shell-level
|
||||
* not-found errors. Consumers match these before denial text because the
|
||||
* command never ran on this path.
|
||||
* Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus
|
||||
* fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit
|
||||
* 1 but its public contract does not reserve that status, while sandbox-exec
|
||||
* publishes no launcher-failure status; those backends remain signature-only.
|
||||
* Keep the Landlock tuple aligned with the assembled snapshot fixture at
|
||||
* `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`.
|
||||
*/
|
||||
const RUNNER_FAILURE_SIGNATURES = {
|
||||
bwrap: ['bwrap: '],
|
||||
landlock: [`${LAUNCHER_BIN}: `],
|
||||
seatbelt: ['sandbox-exec: '],
|
||||
} as const satisfies Record<SelectedRunner['runner'], readonly string[]>
|
||||
const RUNNER_FAILURE_RULES = {
|
||||
bwrap: [{ fatalSignatures: ['bwrap: '] }],
|
||||
landlock: [{
|
||||
allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
|
||||
fatalSignatures: [`${LAUNCHER_BIN}: `],
|
||||
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
|
||||
}],
|
||||
seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
|
||||
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
|
||||
|
||||
/**
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
|
||||
@@ -187,8 +200,8 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
if (runner.length > 0 && runnerFailureSignatures.length === 0) {
|
||||
throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry')
|
||||
}
|
||||
if (runnerFailureSignatures.some(signature => signature.trim().length === 0)) {
|
||||
throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty')
|
||||
if (runnerFailureSignatures.some(signature => signature.trim().length === 0 || /[\r\n]/u.test(signature))) {
|
||||
throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty single-line strings')
|
||||
}
|
||||
this.runnerCommand = runner.length > 0 ? runner : undefined
|
||||
this.configuredRunnerFailureSignatures = runnerFailureSignatures
|
||||
@@ -204,33 +217,25 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
* @param argv - the exact argv the caller is about to spawn.
|
||||
* @param policy - the file-effect policy this execution runs under.
|
||||
* @returns the wrapped argv plus the selected backend's enforcement completeness, denial
|
||||
* signatures, and runner-failure signatures; throws the fail-closed
|
||||
* signatures, and structured runner-failure rules; throws the fail-closed
|
||||
* `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
|
||||
*/
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
if (this.runnerCommand !== undefined) {
|
||||
const argv0 = this.runnerCommand[0] as string
|
||||
return {
|
||||
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
|
||||
enforcement: 'full',
|
||||
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
|
||||
// The operator names the configured runner's own pre-exec refusal dialect; the consumer
|
||||
// additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the
|
||||
// missing/unexecutable outer-shell shapes ourselves.
|
||||
runnerFailureSignatures: [
|
||||
...this.configuredRunnerFailureSignatures,
|
||||
`exec: ${argv0}: not found`,
|
||||
`${argv0}: No such file or directory`,
|
||||
`${argv0}: Permission denied`,
|
||||
],
|
||||
runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
|
||||
}
|
||||
}
|
||||
const selected = this.selectRunner(policy.mode)
|
||||
const runnerArgv = this.runnerArgv(selected.runner, policy)
|
||||
return {
|
||||
argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv],
|
||||
argv: [...runnerArgv, '--', ...argv],
|
||||
enforcement: selected.enforcement,
|
||||
denialSignatures: DENIAL_SIGNATURES[selected.runner],
|
||||
runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner],
|
||||
runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import {
|
||||
@@ -35,7 +36,7 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter
|
||||
function fakeLauncher(report = 'landlock: fully enforced'): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit 125\n`, { mode: 0o755 })
|
||||
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
|
||||
return launcher
|
||||
}
|
||||
|
||||
@@ -111,16 +112,7 @@ describe('runnerCommand config', () => {
|
||||
// An operator runner's kernel mechanism is unknown: both Linux
|
||||
// file-denial dialects, never bare EPERM.
|
||||
denialSignatures: ['read-only file system', 'permission denied'],
|
||||
// The runner's own dialect is unknown, but the consumer re-joins the
|
||||
// wrap through an outer `bash -c 'exec …'` — a missing or
|
||||
// unexecutable runner fails with the OUTER shell's argv0-scoped
|
||||
// shapes, and those classify as sandbox failures like any rung.
|
||||
runnerFailureSignatures: [
|
||||
'fake-runner: profile rejected',
|
||||
'exec: fake-runner: not found',
|
||||
'fake-runner: No such file or directory',
|
||||
'fake-runner: Permission denied',
|
||||
],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: profile rejected'] }],
|
||||
})
|
||||
expect(probeBwrap).not.toHaveBeenCalled()
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
@@ -146,11 +138,14 @@ describe('runnerCommand config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects blank configured-runner failure signatures', async () => {
|
||||
await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [' '] })).rejects.toThrow(
|
||||
'runnerFailureSignatures entries must be non-empty',
|
||||
)
|
||||
})
|
||||
it.each([' ', 'fatal\ncontinued', 'fatal\rcontinued'])(
|
||||
'rejects an unusable configured-runner failure signature %j',
|
||||
async (signature) => {
|
||||
await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [signature] })).rejects.toThrow(
|
||||
'runnerFailureSignatures entries must be non-empty single-line strings',
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('the platform chains', () => {
|
||||
@@ -163,7 +158,7 @@ describe('the platform chains', () => {
|
||||
argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['read-only file system'],
|
||||
runnerFailureSignatures: ['bwrap: '],
|
||||
runnerFailureRules: [{ fatalSignatures: ['bwrap: '] }],
|
||||
})
|
||||
expect(probeLandlock).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -178,14 +173,18 @@ describe('the platform chains', () => {
|
||||
argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['permission denied'],
|
||||
runnerFailureSignatures: ['landlock-run: '],
|
||||
runnerFailureRules: [{
|
||||
allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
|
||||
fatalSignatures: ['landlock-run: '],
|
||||
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
|
||||
}],
|
||||
})
|
||||
expect(probeLandlock).toHaveBeenCalledWith(launcher)
|
||||
})
|
||||
|
||||
it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => {
|
||||
// The safety property moves to execution time: an unusable sandbox-exec
|
||||
// refuses to run the command, and the wrap's runnerFailureSignatures let
|
||||
// refuses to run the command, and the wrap's runnerFailureRules let
|
||||
// the consumer classify that as a sandbox failure, not a task failure.
|
||||
const probeSeatbelt = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt })
|
||||
@@ -194,7 +193,7 @@ describe('the platform chains', () => {
|
||||
argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['operation not permitted'],
|
||||
runnerFailureSignatures: ['sandbox-exec: '],
|
||||
runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }],
|
||||
})
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -311,7 +310,7 @@ describe('the default landlock probe (launcher CLI contract)', () => {
|
||||
it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, '#!/bin/sh\nexit 125\n', { mode: 0o755 })
|
||||
writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
|
||||
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
@@ -360,7 +359,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
|
||||
argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: ['operation not permitted'],
|
||||
runnerFailureSignatures: ['sandbox-exec: '],
|
||||
runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/sandbox/sandbox/README.md
|
||||
README.md: 99f0641560937f66df6db76ae55c90595329792f
|
||||
README.zh.md: dff077779153f15aa54ccc24ca1e8551ce3b75df
|
||||
README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085
|
||||
README.zh.md: adccd4421a74ef073ad3ffc3a23bccb0354d99aa
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
|
||||
|
||||
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined.
|
||||
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus the selected backend's enforcement completeness, denial dialect (`denialSignatures`), and structured runner-failure evidence (`runnerFailureRules`); when no backend is usable it throws rather than passing the argv through unconfined. The [core type catalog](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) owns the exact classifier shape.
|
||||
|
||||
Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy.
|
||||
|
||||
@@ -39,4 +39,5 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions.
|
||||
- **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here.
|
||||
- **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output.
|
||||
- **Runner diagnostics are in-band** — exit status plus stderr evidence cannot prove which process wrote a matching line, so a confined child that deliberately mimics its runner can cause an availability/diagnostic false attribution. This cannot bypass confinement; an out-of-band runner-status channel is deferred.
|
||||
- **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
抽象进程沙箱 seam。负责定义 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包:只依赖 cordis(及 harness 错误基类),绝不依赖后端。
|
||||
|
||||
用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;另附所选后端的两个事实:它达到的强制执行完整度,以及拒绝方言(`denialSignatures`,即内核在文件操作被拒绝时打印到 stderr 的子字符串;通过 stderr 推断的消费方会匹配这些字符串,而不是统一的跨后端签名集合)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。
|
||||
用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。
|
||||
|
||||
策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。
|
||||
|
||||
@@ -39,4 +39,5 @@ sandbox mode "<mode>" is requested but no sandbox backend is usable on this host
|
||||
- **文件操作是完整的策略词汇**:该 seam 不表达网络、进程、系统调用、设备或凭据限制。
|
||||
- **只支持与宿主共享文件系统和内核的限制**:容器、microVM 与远程执行需要替换能力实现,而不是在此处增加提供方。
|
||||
- **拒绝报告是一种 stderr 方言**:该 seam 返回后端签名,而非类型化运行时拒绝通道,因此需要分类的消费方必须从子进程输出推断。
|
||||
- **Runner 诊断使用带内通道**:退出状态与 stderr 证据无法证明匹配行由哪个进程写入,因此受限子进程若故意模仿 runner,就可能造成可用性或诊断误归因。这无法绕过约束;带外 runner 状态通道暂缓实现。
|
||||
- **每个上下文只有一个提供方**:同时组合不同沙箱机制需要提供方级阶梯或独立 Cordis 上下文;调用方逐调用选择策略,而非后端标识。
|
||||
|
||||
@@ -62,6 +62,22 @@ export interface SandboxPolicy extends SandboxExecutionPolicy {
|
||||
mode: ConfinedSandboxMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence that identifies a sandbox runner failing before it executes the
|
||||
* wrapped command. A consumer first applies {@link allowedExitCodes} when
|
||||
* present, removes {@link informationalLines} by case-insensitive exact line
|
||||
* equality, then matches {@link fatalSignatures} case-insensitively within
|
||||
* each remaining stderr line. Exit status alone never proves runner failure.
|
||||
*/
|
||||
export interface RunnerFailureRule {
|
||||
/** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */
|
||||
allowedExitCodes?: readonly number[]
|
||||
/** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */
|
||||
fatalSignatures: readonly string[]
|
||||
/** Benign stderr lines excluded by exact full-line equality before fatal matching. */
|
||||
informationalLines?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link SandboxProvider.confine} result: the argv to spawn in place of
|
||||
* the caller's own, plus the enforcement completeness the selected backend
|
||||
@@ -82,11 +98,12 @@ export interface ConfinedArgv {
|
||||
*/
|
||||
denialSignatures: readonly string[]
|
||||
/**
|
||||
* Case-insensitive signatures for runner failure before command execution.
|
||||
* Consumers check these before denial signatures: runner failure means the
|
||||
* Structured runner-failure evidence rules. Consumers require a matching
|
||||
* fatal stderr line (after informational exclusions) and any rule-specific
|
||||
* exit-code gate before checking denial signatures: runner failure means the
|
||||
* command never ran, while denial means confinement worked and blocked it.
|
||||
*/
|
||||
runnerFailureSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user