- {mode === 'normal' ? field(CODES, 'retryCodes') : null}
- {NUMBER_FIELDS.filter(([path]) => mode === 'normal' || path !== MAX_RETRIES).map(([path, label]) => field(path, label))}
+
+ {mode === 'normal'
+ ? <>
+ {field(MAX_RETRIES, 'retryMaxRetries', `${styles['modelField']} ${styles['retryCount']}`)}
+ {field(CODES, 'retryCodes')}
+ >
+ : null}
+
+ {t('retryBackoff')}
+ {BACKOFF_FIELDS.map(([path, label]) => field(path, label))}
+
)
: null}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index 1c9eed2b28..0d722a95dc 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -57,16 +57,16 @@ export const en = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.',
retryPolicy: 'Retry policy',
- retryMode: 'Retry mode',
retryModeDefault: 'Default ({mode})',
- retryModeNormal: 'Retry transient errors',
- retryModeAlways: 'Retry every error',
+ retryModeNormal: 'Transient errors',
+ retryModeAlways: 'All errors',
retryAlwaysHint: 'Failed requests retry until they succeed or the turn is stopped.',
retryMaxRetries: 'Max retries',
- retryCodes: 'Retryable error codes',
+ retryCodes: 'Error codes',
retryInitialDelay: 'Initial delay (ms)',
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
+ retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'Max retries must be a whole number, 0 or more.',
retryCodesInvalid: 'Error codes must be non-empty text.',
retryCodesDuplicate: 'Each error code may appear once.',
@@ -218,16 +218,16 @@ export const zh: { [Key in keyof typeof en]: string } = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。',
retryPolicy: '重试策略',
- retryMode: '重试模式',
retryModeDefault: '默认({mode})',
- retryModeNormal: '仅重试临时错误',
- retryModeAlways: '重试所有错误',
+ retryModeNormal: '仅临时错误',
+ retryModeAlways: '所有错误',
retryAlwaysHint: '失败的请求会持续重试,直到成功或本轮被停止。',
retryMaxRetries: '最大重试次数',
- retryCodes: '可重试的错误码',
+ retryCodes: '错误码',
retryInitialDelay: '初始延迟 (ms)',
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
+ retryBackoff: '退避',
retryMaxRetriesInvalid: '最大重试次数必须是不小于 0 的整数。',
retryCodesInvalid: '错误码必须是非空文本。',
retryCodesDuplicate: '每个错误码只能出现一次。',
@@ -376,16 +376,16 @@ export const ja = {
'deepSeekBaseUrl': 'https://api.deepseek.com/anthropic (デフォルトはDEEPSEEK_BASE_URLを使用)',
'deepSeekEndpointHint': 'Anthropic Messagesと互換性のあるAPIエンドポイントを使用してください。',
'retryPolicy': '再試行ポリシー',
- 'retryMode': '再試行モード',
'retryModeDefault': 'デフォルト ({mode})',
- 'retryModeNormal': '一時的なエラーのみ再試行',
- 'retryModeAlways': 'すべてのエラーを再試行',
+ 'retryModeNormal': '一時的なエラーのみ',
+ 'retryModeAlways': 'すべてのエラー',
'retryAlwaysHint': '失敗したリクエストは、成功するかターンが停止されるまで再試行されます。',
'retryMaxRetries': '最大再試行回数',
- 'retryCodes': '再試行対象のエラーコード',
+ 'retryCodes': 'エラーコード',
'retryInitialDelay': '初期遅延 (ms)',
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
+ 'retryBackoff': 'バックオフ',
'retryMaxRetriesInvalid': '最大再試行回数は0以上の整数で指定してください。',
'retryCodesInvalid': 'エラーコードは空でないテキストにしてください。',
'retryCodesDuplicate': '各エラーコードは1回のみ指定できます。',
@@ -534,16 +534,16 @@ export const es = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Uso predeterminado DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Utilice un punto final API compatible con Anthropic Messages.',
retryPolicy: 'Política de reintentos',
- retryMode: 'Modo de reintento',
retryModeDefault: 'Predeterminado ({mode})',
- retryModeNormal: 'Reintentar errores transitorios',
- retryModeAlways: 'Reintentar todos los errores',
+ retryModeNormal: 'Errores transitorios',
+ retryModeAlways: 'Todos los errores',
retryAlwaysHint: 'Las solicitudes fallidas se reintentan hasta que tienen éxito o se detiene el turno.',
retryMaxRetries: 'Reintentos máximos',
- retryCodes: 'Códigos de error reintentables',
+ retryCodes: 'Códigos de error',
retryInitialDelay: 'Retraso inicial (ms)',
retryMaxDelay: 'Retraso máximo (ms)',
- retryJitter: 'Proporción de variación aleatoria',
+ retryJitter: 'Proporción de jitter',
+ retryBackoff: 'Retroceso',
retryMaxRetriesInvalid: 'Los reintentos máximos deben ser un número entero igual o mayor que 0.',
retryCodesInvalid: 'Los códigos de error deben ser texto no vacío.',
retryCodesDuplicate: 'Cada código de error solo puede aparecer una vez.',
@@ -692,16 +692,16 @@ export const fr = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (par défaut, DEEPSEEK_BASE_URL est utilisé)',
deepSeekEndpointHint: 'Utilisez un point de terminaison API compatible avec Anthropic Messages.',
retryPolicy: 'Politique de nouvelle tentative',
- retryMode: 'Mode de nouvelle tentative',
retryModeDefault: 'Par défaut ({mode})',
- retryModeNormal: 'Réessayer les erreurs temporaires',
- retryModeAlways: 'Réessayer toutes les erreurs',
+ retryModeNormal: 'Erreurs temporaires',
+ retryModeAlways: 'Toutes les erreurs',
retryAlwaysHint: "Les requêtes en échec sont réessayées jusqu'à ce qu'elles réussissent ou que le tour soit arrêté.",
- retryMaxRetries: 'Nombre maximum de tentatives',
- retryCodes: "Codes d'erreur à réessayer",
+ retryMaxRetries: 'Tentatives max.',
+ retryCodes: "Codes d'erreur",
retryInitialDelay: 'Délai initial (ms)',
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
+ retryBackoff: 'Temporisation',
retryMaxRetriesInvalid: 'Le nombre maximum de tentatives doit être un entier supérieur ou égal à 0.',
retryCodesInvalid: "Les codes d'erreur doivent être du texte non vide.",
retryCodesDuplicate: "Chaque code d'erreur ne peut apparaître qu'une fois.",
@@ -850,16 +850,16 @@ export const de = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Standardmäßig wird DEEPSEEK_BASE_URL verwendet)',
deepSeekEndpointHint: 'Verwenden Sie einen API-Endpunkt, der mit Anthropic Messages kompatibel ist.',
retryPolicy: 'Wiederholungsrichtlinie',
- retryMode: 'Wiederholungsmodus',
retryModeDefault: 'Standard ({mode})',
- retryModeNormal: 'Vorübergehende Fehler wiederholen',
- retryModeAlways: 'Alle Fehler wiederholen',
+ retryModeNormal: 'Vorübergehende Fehler',
+ retryModeAlways: 'Alle Fehler',
retryAlwaysHint: 'Fehlgeschlagene Anfragen werden wiederholt, bis sie erfolgreich sind oder der Durchlauf gestoppt wird.',
- retryMaxRetries: 'Maximale Anzahl an Wiederholungen',
- retryCodes: 'Wiederholbare Fehlercodes',
- retryInitialDelay: 'Anfängliche Verzögerung (ms)',
- retryMaxDelay: 'Maximale Verzögerung (ms)',
+ retryMaxRetries: 'Max. Wiederholungen',
+ retryCodes: 'Fehlercodes',
+ retryInitialDelay: 'Anfangsverzögerung (ms)',
+ retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
+ retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'Die maximale Anzahl an Wiederholungen muss eine ganze Zahl ab 0 sein.',
retryCodesInvalid: 'Fehlercodes müssen nicht leerer Text sein.',
retryCodesDuplicate: 'Jeder Fehlercode darf nur einmal vorkommen.',
@@ -1008,16 +1008,16 @@ export const ru = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (по умолчанию используется DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Используйте конечную точку API, совместимую с антропными сообщениями.',
retryPolicy: 'Политика повторных попыток',
- retryMode: 'Режим повторных попыток',
retryModeDefault: 'По умолчанию ({mode})',
- retryModeNormal: 'Повторять при временных ошибках',
- retryModeAlways: 'Повторять при любых ошибках',
+ retryModeNormal: 'Временные ошибки',
+ retryModeAlways: 'Любые ошибки',
retryAlwaysHint: 'Неудачные запросы повторяются до успеха или до остановки хода.',
- retryMaxRetries: 'Максимальное количество повторных попыток',
- retryCodes: 'Коды ошибок для повтора',
+ retryMaxRetries: 'Макс. число повторов',
+ retryCodes: 'Коды ошибок',
retryInitialDelay: 'Начальная задержка (ms)',
- retryMaxDelay: 'Максимальная задержка (ms)',
+ retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
+ retryBackoff: 'Отсрочка',
retryMaxRetriesInvalid: 'Максимальное количество повторных попыток должно быть целым числом не меньше 0.',
retryCodesInvalid: 'Коды ошибок должны быть непустым текстом.',
retryCodesDuplicate: 'Каждый код ошибки может встречаться только один раз.',
@@ -1166,16 +1166,16 @@ export const pt = {
deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (padrão para usar DEEPSEEK_BASE_URL)',
deepSeekEndpointHint: 'Use um endpoint API compatível com Anthropic Messages.',
retryPolicy: 'Política de novas tentativas',
- retryMode: 'Modo de novas tentativas',
retryModeDefault: 'Padrão ({mode})',
- retryModeNormal: 'Tentar novamente erros transitórios',
- retryModeAlways: 'Tentar novamente todos os erros',
+ retryModeNormal: 'Erros transitórios',
+ retryModeAlways: 'Todos os erros',
retryAlwaysHint: 'As solicitações com falha são repetidas até terem sucesso ou até o turno ser interrompido.',
- retryMaxRetries: 'Máximo de novas tentativas',
- retryCodes: 'Códigos de erro que permitem nova tentativa',
+ retryMaxRetries: 'Máx. de novas tentativas',
+ retryCodes: 'Códigos de erro',
retryInitialDelay: 'Atraso inicial (ms)',
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
+ retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'O máximo de novas tentativas deve ser um número inteiro maior ou igual a 0.',
retryCodesInvalid: 'Os códigos de erro devem ser texto não vazio.',
retryCodesDuplicate: 'Cada código de erro só pode aparecer uma vez.',
diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
index 3a25dc45af..1d392f90fc 100644
--- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
@@ -69,7 +69,7 @@ function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) {
return { writeSettings, onClose }
}
-const mode = () => screen.getByLabelText
(en.retryMode)
+const mode = () => screen.getByRole('combobox', { name: en.retryPolicy })
const input = (label: string) => screen.getByLabelText(label)
const apply = () => screen.getByText(en.apply)
From 52dc37d9fe8c2bbcfa02e794321d7423cbecd858 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 23 Oct 2026 15:53:29 +0800
Subject: [PATCH 005/106] feat(ui-settings-models): pick retryable error codes
from a labelled list
---
.../ui-settings-models/README.i18n.yaml | 4 +-
packages/client/ui-settings-models/README.md | 2 +-
.../client/ui-settings-models/README.zh.md | 2 +-
.../src/client/ModelsSection.module.css | 7 ++
.../src/client/RetryPolicyEditor.tsx | 70 ++++++++++++++++---
.../ui-settings-models/src/client/locales.ts | 64 +++++++++++++++++
.../tests/retry-policy.client.spec.tsx | 50 ++++++++++---
7 files changed, 177 insertions(+), 22 deletions(-)
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index c571d9b3bc..d862c5b81a 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -21,8 +21,8 @@
en: f4817eb2e3537ed9
zh: e589c193fa85473f
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
- en: 57b27798e605dcb2
- zh: 04bacb88db40a237
+ en: 00c034b43eef5741
+ zh: 4eb3f646725dddbb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: c0b04b2b11af60d3
zh: d195eec281e445cb
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index c8ae8f23ab..227241213f 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits.
-The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the comma-separated error codes and the maximum retry count; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
+The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index c97e567754..9e3d8b9975 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -55,7 +55,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。
-同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑逗号分隔的错误码和最大重试次数;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
+同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
index c79a4deef0..85b4218596 100644
--- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css
+++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
@@ -638,6 +638,13 @@
margin-top: 4px;
}
+/* Code checkboxes in as many even columns as fit. */
+.retryCodeChoices {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 6px 16px;
+}
+
/* One backoff column wide, so the count lines up with the delay fields below. */
.retryCount {
width: max(112px, calc((100% - 16px) / 3));
diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
index 0f300121b4..e35e04373b 100644
--- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
@@ -8,6 +8,7 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
+import { Checkbox } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import type { ModelsKey } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -21,7 +22,7 @@ const INITIAL_DELAY = ['backoff', 'initialDelayMs'] as const
const MAX_DELAY = ['backoff', 'maxDelayMs'] as const
const JITTER = ['backoff', 'jitterRatio'] as const
-type FieldPath = typeof CODES | typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER
+type FieldPath = typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER
/** Backoff fields of both modes, as `[policy path, label]`. */
const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [
@@ -30,6 +31,22 @@ const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [
[JITTER, 'retryJitter'],
]
+/**
+ * Stable `LlmFailure` codes offered as retryable choices, with their labels: the
+ * transient defaults of `dsh-llm`, then the other request failures a retry can
+ * plausibly recover. A stored code outside this list stays listed after them.
+ */
+const CODE_CHOICES: ReadonlyMap = new Map([
+ ['EMPTY_RESPONSE', 'retryCodeEmptyResponse'],
+ ['RATE_LIMIT', 'retryCodeRateLimit'],
+ ['SERVER', 'retryCodeServer'],
+ ['TIMEOUT', 'retryCodeTimeout'],
+ ['TRANSPORT', 'retryCodeTransport'],
+ ['STREAM_CLOSED', 'retryCodeStreamClosed'],
+ ['MALFORMED_RESPONSE', 'retryCodeMalformedResponse'],
+ ['QUOTA', 'retryCodeQuota'],
+])
+
/** Plain decimal text; prefixed forms such as `0x10` stay invalid text. */
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu
@@ -149,12 +166,11 @@ export interface RetryPolicyEditorProps {
}
function textOf(value: unknown): string {
- if (Array.isArray(value)) return value.join(', ')
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
/** Set or remove one field of a plain policy object, dropping an emptied `backoff`. */
-function withField(policy: Record, path: FieldPath, value: unknown): Record {
+function withField(policy: Record, path: FieldPath | typeof CODES, value: unknown): Record {
const assign = (target: Record, key: string, next: unknown): void => {
if (next === undefined) Reflect.deleteProperty(target, key)
else target[key] = next
@@ -194,11 +210,8 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
const edit = (path: FieldPath, text: string): void => {
const trimmed = text.trim()
setTexts(previous => ({ ...previous, [path.join('.')]: trimmed.length === 0 ? '' : text }))
- const value = path === CODES
- ? trimmed.split(',').map(code => code.trim()).filter(code => code.length > 0)
- : DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
- const empty = Array.isArray(value) ? value.length === 0 : value === ''
- props.onChange(withField(current, path, empty ? undefined : value))
+ const value = DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
+ props.onChange(withField(current, path, value === '' ? undefined : value))
}
const field = (path: FieldPath, label: ModelsKey, className = styles['modelField']): ReactNode => {
const invalid = failure?.path?.join('.') === path.join('.')
@@ -208,7 +221,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
)
}
+ const codesField = (): ReactNode => {
+ const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(code => typeof code === 'string') : []
+ const inherited = strings(defaults.value(CODES))
+ const stored = current['retryableCodes']
+ const selected = Array.isArray(stored) ? strings(stored) : inherited
+ const choices = [...new Set([...CODE_CHOICES.keys(), ...inherited, ...selected])]
+ const invalid = failure?.path?.join('.') === CODES.join('.')
+ return (
+
+ {t('retryCodes')}
+
+ {choices.map((code) => {
+ const labelKey = CODE_CHOICES.get(code)
+ return (
+ {
+ const next = choices.filter(value => value === code ? checked : selected.includes(value))
+ // Selecting exactly the inherited codes keeps the field inherited.
+ const inherits = next.length === inherited.length && next.every(value => inherited.includes(value))
+ props.onChange(withField(current, CODES, inherits ? undefined : next))
+ }}
+ />
+ )
+ })}
+
+
+ )
+ }
const labelId = `${props.failureId}-label`
const modeLabel = (value: 'normal' | 'always'): string => t(value === 'normal' ? 'retryModeNormal' : 'retryModeAlways')
return (
@@ -256,7 +306,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
{mode === 'normal'
? <>
{field(MAX_RETRIES, 'retryMaxRetries', `${styles['modelField']} ${styles['retryCount']}`)}
- {field(CODES, 'retryCodes')}
+ {codesField()}
>
: null}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index 0d722a95dc..f0c1ad2cd8 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -63,6 +63,14 @@ export const en = {
retryAlwaysHint: 'Failed requests retry until they succeed or the turn is stopped.',
retryMaxRetries: 'Max retries',
retryCodes: 'Error codes',
+ retryCodeEmptyResponse: 'Empty response',
+ retryCodeRateLimit: 'Rate limited',
+ retryCodeServer: 'Server error',
+ retryCodeTimeout: 'Timeout',
+ retryCodeTransport: 'Network error',
+ retryCodeStreamClosed: 'Stream closed early',
+ retryCodeMalformedResponse: 'Malformed response',
+ retryCodeQuota: 'Quota exceeded',
retryInitialDelay: 'Initial delay (ms)',
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
@@ -224,6 +232,14 @@ export const zh: { [Key in keyof typeof en]: string } = {
retryAlwaysHint: '失败的请求会持续重试,直到成功或本轮被停止。',
retryMaxRetries: '最大重试次数',
retryCodes: '错误码',
+ retryCodeEmptyResponse: '空响应',
+ retryCodeRateLimit: '请求限流',
+ retryCodeServer: '服务器错误',
+ retryCodeTimeout: '超时',
+ retryCodeTransport: '网络错误',
+ retryCodeStreamClosed: '流提前结束',
+ retryCodeMalformedResponse: '响应格式错误',
+ retryCodeQuota: '配额已用尽',
retryInitialDelay: '初始延迟 (ms)',
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
@@ -382,6 +398,14 @@ export const ja = {
'retryAlwaysHint': '失敗したリクエストは、成功するかターンが停止されるまで再試行されます。',
'retryMaxRetries': '最大再試行回数',
'retryCodes': 'エラーコード',
+ 'retryCodeEmptyResponse': '空のレスポンス',
+ 'retryCodeRateLimit': 'レート制限',
+ 'retryCodeServer': 'サーバーエラー',
+ 'retryCodeTimeout': 'タイムアウト',
+ 'retryCodeTransport': 'ネットワークエラー',
+ 'retryCodeStreamClosed': 'ストリームの途中終了',
+ 'retryCodeMalformedResponse': '不正なレスポンス',
+ 'retryCodeQuota': 'クォータ超過',
'retryInitialDelay': '初期遅延 (ms)',
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
@@ -540,6 +564,14 @@ export const es = {
retryAlwaysHint: 'Las solicitudes fallidas se reintentan hasta que tienen éxito o se detiene el turno.',
retryMaxRetries: 'Reintentos máximos',
retryCodes: 'Códigos de error',
+ retryCodeEmptyResponse: 'Respuesta vacía',
+ retryCodeRateLimit: 'Límite de frecuencia',
+ retryCodeServer: 'Error del servidor',
+ retryCodeTimeout: 'Tiempo agotado',
+ retryCodeTransport: 'Error de red',
+ retryCodeStreamClosed: 'Flujo cerrado antes de tiempo',
+ retryCodeMalformedResponse: 'Respuesta mal formada',
+ retryCodeQuota: 'Cuota agotada',
retryInitialDelay: 'Retraso inicial (ms)',
retryMaxDelay: 'Retraso máximo (ms)',
retryJitter: 'Proporción de jitter',
@@ -698,6 +730,14 @@ export const fr = {
retryAlwaysHint: "Les requêtes en échec sont réessayées jusqu'à ce qu'elles réussissent ou que le tour soit arrêté.",
retryMaxRetries: 'Tentatives max.',
retryCodes: "Codes d'erreur",
+ retryCodeEmptyResponse: 'Réponse vide',
+ retryCodeRateLimit: 'Limite de débit',
+ retryCodeServer: 'Erreur serveur',
+ retryCodeTimeout: 'Délai dépassé',
+ retryCodeTransport: 'Erreur réseau',
+ retryCodeStreamClosed: 'Flux interrompu',
+ retryCodeMalformedResponse: 'Réponse mal formée',
+ retryCodeQuota: 'Quota dépassé',
retryInitialDelay: 'Délai initial (ms)',
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
@@ -856,6 +896,14 @@ export const de = {
retryAlwaysHint: 'Fehlgeschlagene Anfragen werden wiederholt, bis sie erfolgreich sind oder der Durchlauf gestoppt wird.',
retryMaxRetries: 'Max. Wiederholungen',
retryCodes: 'Fehlercodes',
+ retryCodeEmptyResponse: 'Leere Antwort',
+ retryCodeRateLimit: 'Ratenbegrenzung',
+ retryCodeServer: 'Serverfehler',
+ retryCodeTimeout: 'Zeitüberschreitung',
+ retryCodeTransport: 'Netzwerkfehler',
+ retryCodeStreamClosed: 'Stream vorzeitig beendet',
+ retryCodeMalformedResponse: 'Fehlerhafte Antwort',
+ retryCodeQuota: 'Kontingent erschöpft',
retryInitialDelay: 'Anfangsverzögerung (ms)',
retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
@@ -1014,6 +1062,14 @@ export const ru = {
retryAlwaysHint: 'Неудачные запросы повторяются до успеха или до остановки хода.',
retryMaxRetries: 'Макс. число повторов',
retryCodes: 'Коды ошибок',
+ retryCodeEmptyResponse: 'Пустой ответ',
+ retryCodeRateLimit: 'Лимит запросов',
+ retryCodeServer: 'Ошибка сервера',
+ retryCodeTimeout: 'Тайм-аут',
+ retryCodeTransport: 'Ошибка сети',
+ retryCodeStreamClosed: 'Поток прерван',
+ retryCodeMalformedResponse: 'Некорректный ответ',
+ retryCodeQuota: 'Квота исчерпана',
retryInitialDelay: 'Начальная задержка (ms)',
retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
@@ -1172,6 +1228,14 @@ export const pt = {
retryAlwaysHint: 'As solicitações com falha são repetidas até terem sucesso ou até o turno ser interrompido.',
retryMaxRetries: 'Máx. de novas tentativas',
retryCodes: 'Códigos de erro',
+ retryCodeEmptyResponse: 'Resposta vazia',
+ retryCodeRateLimit: 'Limite de taxa',
+ retryCodeServer: 'Erro do servidor',
+ retryCodeTimeout: 'Tempo esgotado',
+ retryCodeTransport: 'Erro de rede',
+ retryCodeStreamClosed: 'Fluxo encerrado cedo',
+ retryCodeMalformedResponse: 'Resposta malformada',
+ retryCodeQuota: 'Cota esgotada',
retryInitialDelay: 'Atraso inicial (ms)',
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
index 1d392f90fc..7d8ef15daa 100644
--- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
@@ -71,6 +71,15 @@ function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) {
const mode = () => screen.getByRole('combobox', { name: en.retryPolicy })
const input = (label: string) => screen.getByLabelText(label)
+const CODE_LABELS: Record = {
+ EMPTY_RESPONSE: en.retryCodeEmptyResponse,
+ RATE_LIMIT: en.retryCodeRateLimit,
+ SERVER: en.retryCodeServer,
+ TIMEOUT: en.retryCodeTimeout,
+ TRANSPORT: en.retryCodeTransport,
+ STREAM_CLOSED: en.retryCodeStreamClosed,
+}
+const code_ = (code: string) => screen.getByRole('checkbox', { name: CODE_LABELS[code] ?? code })
const apply = () => screen.getByText(en.apply)
describe('retry policy fields', () => {
@@ -82,13 +91,14 @@ describe('retry policy fields', () => {
fireEvent.change(mode(), { target: { value: 'normal' } })
expect(input(en.retryMaxRetries).placeholder).toBe('5')
- expect(input(en.retryCodes).placeholder).toBe('EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT')
+ expect(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'].every(code => code_(code).checked)).toBe(true)
+ expect(code_('STREAM_CLOSED').checked).toBe(false)
expect(input(en.retryInitialDelay).placeholder).toBe('500')
expect(input(en.retryMaxDelay).placeholder).toBe('10000')
expect(input(en.retryJitter).placeholder).toBe('0.1')
fireEvent.change(input(en.retryMaxRetries), { target: { value: '3' } })
- fireEvent.change(input(en.retryCodes), { target: { value: ' RATE_LIMIT, ,SERVER ' } })
+ for (const code of ['EMPTY_RESPONSE', 'TIMEOUT', 'TRANSPORT']) fireEvent.click(code_(code))
fireEvent.change(input(en.retryJitter), { target: { value: '0.' } })
expect(input(en.retryJitter).value).toBe('0.')
fireEvent.change(input(en.retryJitter), { target: { value: '0.25' } })
@@ -108,7 +118,6 @@ describe('retry policy fields', () => {
[en.retryMaxRetries, '-1', en.retryMaxRetriesInvalid],
[en.retryMaxRetries, '1.5', en.retryMaxRetriesInvalid],
[en.retryMaxRetries, 'many', en.retryMaxRetriesInvalid],
- [en.retryCodes, 'SERVER, SERVER', en.retryCodesDuplicate],
[en.retryInitialDelay, '0', delayInvalid],
[en.retryInitialDelay, '0x10', delayInvalid],
[en.retryMaxDelay, '3000000000', delayInvalid],
@@ -174,15 +183,38 @@ describe('retry policy fields', () => {
})
it.each([
- ['an empty code list', []],
- ['a blank code', ['']],
- ['a numeric code', [500]],
- ])('refuses a stored policy with %s', (_name, codes) => {
+ ['an empty code list', [], en.retryCodesInvalid],
+ ['a blank code', [''], en.retryCodesInvalid],
+ ['a numeric code', [500], en.retryCodesInvalid],
+ ['a repeated code', ['SERVER', 'SERVER'], en.retryCodesDuplicate],
+ ])('refuses a stored policy with %s', (_name, codes, message) => {
setup(deepSeekNamespace({ user: { retryPolicy: { mode: 'normal', retryableCodes: codes } } }))
- expect(screen.getByText(en.retryCodesInvalid)).toBeTruthy()
+ expect(screen.getByText(message)).toBeTruthy()
+ expect(screen.getByRole('group', { name: en.retryCodes }).getAttribute('aria-invalid')).toBe('true')
expect(apply().disabled).toBe(true)
})
+ it('picks codes from a list, keeps stored custom codes, and inherits an unchanged selection', async () => {
+ const { writeSettings } = setup(deepSeekNamespace({
+ base: { retryPolicy: { mode: 'normal', retryableCodes: ['SERVER', 'CUSTOM'] } },
+ user: { retryPolicy: { mode: 'normal' } },
+ }))
+ expect(code_('CUSTOM').checked).toBe(true)
+ expect(code_('RATE_LIMIT').checked).toBe(false)
+ fireEvent.click(code_('CUSTOM'))
+ expect(code_('SERVER').disabled).toBe(true)
+ fireEvent.click(code_('STREAM_CLOSED'))
+ fireEvent.click(code_('STREAM_CLOSED'))
+ fireEvent.click(code_('CUSTOM'))
+ expect(apply().disabled).toBe(false)
+ fireEvent.click(code_('RATE_LIMIT'))
+ fireEvent.click(apply())
+ await waitFor(() => { expect(writeSettings).toHaveBeenCalled() })
+ expect(writeSettings.mock.calls[0]?.[1]).toEqual([
+ { op: 'set', path: ['retryPolicy', 'retryableCodes'], value: ['RATE_LIMIT', 'SERVER', 'CUSTOM'] },
+ ])
+ })
+
it('refuses a stored policy that is not an object', () => {
setup(deepSeekNamespace({ user: { retryPolicy: 'sometimes' } }))
expect(mode().value).toBe('')
@@ -254,6 +286,8 @@ describe('retry policy fields', () => {
setup({ ...namespace, schema: json(Schema.object({ retryPolicy: always })) })
fireEvent.change(mode(), { target: { value: 'always' } })
expect(input(en.retryMaxDelay).placeholder).toBe('')
+ fireEvent.change(mode(), { target: { value: 'normal' } })
+ expect(code_('SERVER').checked).toBe(false)
})
it('renders no retry fields when the namespace schema has no retry policy', () => {
From 83a8d0a0bf24739d07ceb979af3052558f287c04 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 01:23:04 +0800
Subject: [PATCH 006/106] fix(llm-pi-ai): omit Anthropic thinking whose request
prefix changed
Anthropic binds signed thinking to the system prompt, tools, and earlier
messages. Compaction and tool-result pruning rewrite that prefix, so
retained responses replayed stale signatures and enforcing endpoints
rejected every later request. Record a prefix digest with each Anthropic
Messages response and drop thinking from replayed responses whose prefix
digest changed or was never recorded.
Fixes #7582
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/README.i18n.yaml | 16 +-
packages/llm/llm-pi-ai/README.md | 7 +-
packages/llm/llm-pi-ai/README.zh.md | 7 +-
packages/llm/llm-pi-ai/src/adapter.ts | 19 +-
packages/llm/llm-pi-ai/src/context.ts | 22 ++-
packages/llm/llm-pi-ai/src/index.ts | 3 +
packages/llm/llm-pi-ai/src/prefix-binding.ts | 93 +++++++++
packages/llm/llm-pi-ai/src/replay.ts | 30 ++-
packages/llm/llm-pi-ai/src/stream.ts | 4 +-
packages/llm/llm-pi-ai/tests/convert.spec.ts | 1 +
.../llm-pi-ai/tests/prefix-binding.spec.ts | 178 ++++++++++++++++++
11 files changed, 352 insertions(+), 28 deletions(-)
create mode 100644 packages/llm/llm-pi-ai/src/prefix-binding.ts
create mode 100644 packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index 635adc8d9f..a1dbdb1b1d 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -51,8 +51,8 @@
en: 615ca9834bf6544f
zh: 2d70b548f99000d1
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/source-map:
- en: 64912b8a3a336419
- zh: 33a245969b4194f6
+ en: 01520c709ee2202c
+ zh: de919d3f2ff61046
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/registration-and-directory:
en: 0c6df3e8b6a75372
zh: 476666a6f209542c
@@ -60,8 +60,8 @@
en: 477d277e9ac35c8f
zh: 96870ca333fb6510
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary:
- en: e64173763fdbcfae
- zh: 73ed2c2af54b62f6
+ en: 507ceb2856849646
+ zh: 4f54925b52a1c190
/deepseek-ai-dsh-llm-pi-ai/further-exploration:
en: a2a2ccdeae59d408
zh: c47ee091aa90ad91
@@ -72,14 +72,14 @@
en: e7348db3f7115960
zh: 5fd5fb09d7d09982
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/what-the-model-sees:
- en: a1c686712365d7ae
- zh: f28c4e2373013a2a
+ en: 5a721ff77ea17329
+ zh: a6939df9086ffbe4
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/token-effect:
en: 820221e8ae37aa61
zh: 7e6ff4db10067fc0
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/kv-cache-effect:
- en: 7a2595060340d7d5
- zh: e4ba753dcdaa89fc
+ en: f12ebd22715827ea
+ zh: 8f006b1be832bcc7
/deepseek-ai-dsh-llm-pi-ai/model-experience/provider-response:
en: 802d7ff60a1d03ff
zh: c41c56e28f3e8680
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 1021dea71d..68cf9c77d3 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -162,6 +162,7 @@ This event covers inference calls with or without a Session id, including auxili
| [`src/context.ts`](src/context.ts) | Harness-to-pi-ai context conversion, image handling, replay restore |
| [`src/stream.ts`](src/stream.ts) | pi-ai event conversion into harness `StreamChunk` values |
| [`src/replay.ts`](src/replay.ts) | Versioned `ReplayEnvelope` storage and validation |
+| [`src/prefix-binding.ts`](src/prefix-binding.ts) | Anthropic thinking omission after a request-prefix change |
| [`src/discovery.ts`](src/discovery.ts) | Endpoint interrogation for configuration surfaces |
### Registration and directory
@@ -191,6 +192,8 @@ The `pre-commit` hook runs `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and uses it to retain same-model signatures; provider-reported response models remain diagnostic metadata. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks. Protocol conversion operates on request-local copies. Switching from A to B and back to A preserves the stored content, including empty text blocks, tool ids, original block order, and native replay metadata; requests to a different provider, API, or model omit unsupported signatures and reasoning blocks. Degraded replay without native metadata also omits reasoning. Valid native replay retains the stored reasoning when the original model resumes.
+Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's thinking and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text and tool calls remain, and omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues keeps its thinking. The digest covers the pi-ai context only; switching authentication or compat settings that change the wire request mid-conversation is not detected.
+
-----
@@ -216,7 +219,7 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
-The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order when the catalog model declares `supportsMidConvoSystemMessages`. Otherwise, it converts each non-empty later system message to a user message wrapped in `` before passing it to pi-ai. The wrapper does not grant system-role priority. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source.
+The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order when the catalog model declares `supportsMidConvoSystemMessages`. Otherwise, it converts each non-empty later system message to a user message wrapped in `` before passing it to pi-ai. The wrapper does not grant system-role priority. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. On Anthropic Messages routes, a replayed response whose request prefix changed since generation, for example through compaction, loses its thinking blocks; its text and tool calls remain. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source.
#### Token effect
@@ -224,7 +227,7 @@ Provider tokenization governs exact input. Retained images add the stable attach
#### KV Cache effect
-Later system messages supplied to the adapter stay out of the initial prompt, preserving the preceding request prefix. This does not change the agent loop’s prompt admission, which consolidates updates on routes without native system-update support. Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards.
+Later system messages supplied to the adapter stay out of the initial prompt, preserving the preceding request prefix. This does not change the agent loop’s prompt admission, which consolidates updates on routes without native system-update support. Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. Omitting stale Anthropic thinking follows a prefix change that already ended reuse at the changed message. The first request that omits thinking recorded without a digest ends reuse at the first such message once. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards.
### Provider response
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index 1526faa4b8..620fd428f6 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -162,6 +162,7 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的
| [`src/context.ts`](src/context.ts) | Harness 到 pi-ai 的上下文转换、图片处理、回放恢复 |
| [`src/stream.ts`](src/stream.ts) | 把 pi-ai 事件转换为 harness `StreamChunk` 值 |
| [`src/replay.ts`](src/replay.ts) | 带版本的 `ReplayEnvelope` 存储与校验 |
+| [`src/prefix-binding.ts`](src/prefix-binding.ts) | 请求前缀变化后省略 Anthropic thinking |
| [`src/discovery.ts`](src/discovery.ts) | 面向配置界面的端点询问 |
### 注册与目录
@@ -191,6 +192,8 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并用它保留同模型签名;提供方报告的响应模型仅作为诊断元数据。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。 协议转换仅作用于请求副本。从 A 切换到 B 再切回 A 时,存储的内容保持完整,包括空文本块、工具 id、原始块顺序和原生回放元数据;发给不同提供方、API 或模型的请求会省略不受支持的签名和推理块。缺少原生元数据的降级回放也会省略推理。切回原模型时,有效的原生回放会保留已存储的推理。
+Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本和工具调用保留,已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当模型的 compat 未设置 `forceAdaptiveThinking` 时,请求所延续的工具轮次对应的 assistant 消息会保留其 thinking。摘要只覆盖 pi-ai 上下文;对话中途切换认证或 compat 设置而改变线上请求时,不会被检测到。
+
-----
@@ -216,7 +219,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
#### 模型看到什么
-所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。
+所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。在 Anthropic Messages 路由上,如果回放响应的请求前缀自生成后发生变化(例如经过压缩),该响应会丢失 thinking 块;其文本和工具调用保留。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。
#### Token 影响
@@ -224,7 +227,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
#### KV Cache 影响
-传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。
+传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。省略过期的 Anthropic thinking 发生在前缀变化之后,而该变化已在变化的消息处结束复用。第一个省略无摘要记录的 thinking 的请求会在第一条此类消息处结束复用,且仅发生一次。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。
### 提供方响应
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index 4c3bdce845..0f8c53f25d 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -62,6 +62,8 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
import { requestImageTarget, toPiContext } from './context.ts'
import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts'
import { toStreamChunks } from './stream.ts'
+import { bindThinkingToPrefix } from './prefix-binding.ts'
+import type { PrefixDigests } from './replay.ts'
import type { PiAiRequestHeaderContext } from './types.ts'
/**
@@ -126,6 +128,12 @@ export interface PiAiAdapterOptions {
* conversion because its stored replay state is unusable by this build.
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
+ /**
+ * Observe one request omitting replayed Anthropic thinking whose request
+ * prefix changed or was never recorded; stale messages stay omitted, so
+ * every later request of the conversation reports them again.
+ */
+ onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void
}
/** The two auth injectables a pi-ai collection is built with. */
@@ -444,8 +452,9 @@ export class PiAiAdapter extends LlmAdapter {
const onReplayDegrade = (reason: string): void => {
this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
}
- const context = attachments === undefined
- ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history')
+ const prefixDigests: PrefixDigests = new Map()
+ const converted = attachments === undefined
+ ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
: await toPiContext({ ...request, signal: watchdog.signal }, {
attachments,
resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
@@ -454,7 +463,9 @@ export class PiAiAdapter extends LlmAdapter {
maxPixels: profile.requestImagePixelBudget,
maxBytes: profile.requestImageMaxBytes,
},
- }, onReplayDegrade, updates.systemPromptUpdate === 'in-history')
+ }, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
+ const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests)
+ if (omitted > 0) this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
@@ -463,7 +474,7 @@ export class PiAiAdapter extends LlmAdapter {
signal: watchdog.signal,
headers: requestHeaders(profile.headers, options, model.baseUrl, this.config.prepareHeaders),
})
- const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]()
+ const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id, digest)[Symbol.asyncIterator]()
let exhausted = false
try {
while (true) {
diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts
index f884bb049a..3d5f9d91b7 100644
--- a/packages/llm/llm-pi-ai/src/context.ts
+++ b/packages/llm/llm-pi-ai/src/context.ts
@@ -16,6 +16,7 @@ import type {
} from '@deepseek-ai/dsh-attachment'
import type { Context as PiContext, ImageContent, Message as PiMessage, SystemMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
import { toPiAssistant } from './replay.ts'
+import type { PrefixDigests } from './replay.ts'
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts'
@@ -205,8 +206,9 @@ function appendAssistant(
messages: PiMessage[],
toolNames: Map,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): void {
- const assistant = toPiAssistant(message, onReplayDegrade)
+ const assistant = toPiAssistant(message, onReplayDegrade, prefixDigests)
for (const block of assistant.content) {
if (block.type === 'toolCall') toolNames.set(brandString(block.id), block.name)
}
@@ -221,6 +223,7 @@ function appendSharedHistory(
toolNames: Map,
supportsMidConvoSystemMessages: boolean,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): boolean {
if (message.role === 'developer') {
appendDeveloper(message, options, messages)
@@ -239,7 +242,7 @@ function appendSharedHistory(
return true
}
if (message.role === 'assistant') {
- appendAssistant(message, messages, toolNames, onReplayDegrade)
+ appendAssistant(message, messages, toolNames, onReplayDegrade, prefixDigests)
return true
}
return false
@@ -249,6 +252,7 @@ function textOnlyContext(
options: GenerateOptions,
supportsMidConvoSystemMessages: boolean,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): PiContext {
assertSupportedHistory(options.messages)
const split = splitSystemPrompt(options)
@@ -258,7 +262,7 @@ function textOnlyContext(
if (contentHasImage(message.content)) {
throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
- if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue
+ if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)) continue
if (message.role === 'tool') {
messages.push(toolResultOf(message, toolNames, flattenText(message)))
continue
@@ -305,6 +309,7 @@ export function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageReque
* @param images - absent; selects the synchronous conversion.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder.
+ * @param prefixDigests - receives the recorded request-prefix digest of each replayed assistant message that has one.
* @returns the pi-ai context; `tools` is omitted when the request declares none.
* @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message.
*/
@@ -313,6 +318,7 @@ export function toPiContext(
images?: undefined,
onReplayDegrade?: (reason: string) => void,
supportsMidConvoSystemMessages?: boolean,
+ prefixDigests?: PrefixDigests,
): PiContext
/**
* Convert harness history to a pi-ai Context while resolving durable images.
@@ -325,6 +331,7 @@ export function toPiContext(
* @param images - attachment provider, current path resolver, and request limits.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder.
+ * @param prefixDigests - receives the recorded request-prefix digest of each replayed assistant message that has one.
* @returns the asynchronously resolved pi-ai context.
*/
export function toPiContext(
@@ -332,16 +339,18 @@ export function toPiContext(
images: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
supportsMidConvoSystemMessages?: boolean,
+ prefixDigests?: PrefixDigests,
): Promise
export function toPiContext(
options: GenerateOptions,
images?: PiImageRequestContext,
onReplayDegrade?: (reason: string) => void,
supportsMidConvoSystemMessages = false,
+ prefixDigests?: PrefixDigests,
): PiContext | Promise {
return images === undefined
- ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade)
- : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade)
+ ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)
+ : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)
}
async function toPiContextWithImages(
@@ -349,6 +358,7 @@ async function toPiContextWithImages(
images: PiImageRequestContext,
supportsMidConvoSystemMessages: boolean,
onReplayDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
): Promise {
const { attachments, resolveImageAccess, maxRequestImageBytes } = images
const requestImagePolicy = images.requestImagePolicy ?? {
@@ -380,7 +390,7 @@ async function toPiContextWithImages(
const messages: PiMessage[] = []
for (const message of exactMessages) {
- if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue
+ if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)) continue
if (message.role === 'tool') {
messages.push(toolResultOf(message, toolNames, userContent(message.content, requestImages, resolveImageAccess)))
continue
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index 5a53e7106a..a671b7229a 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -252,6 +252,9 @@ export function apply(ctx: Context, config: Config): void {
+ ` sending that message as provider-neutral content (${reason})`,
)
},
+ onThinkingOmitted: ({ provider, model, count }) => {
+ ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
+ },
})
// Independent of the route set: signing in is what makes a route worth
// adding, so the flows are offered before any profile names their provider.
diff --git a/packages/llm/llm-pi-ai/src/prefix-binding.ts b/packages/llm/llm-pi-ai/src/prefix-binding.ts
new file mode 100644
index 0000000000..d5ed0451df
--- /dev/null
+++ b/packages/llm/llm-pi-ai/src/prefix-binding.ts
@@ -0,0 +1,93 @@
+/**
+ * Request-prefix binding for replayed Anthropic thinking.
+ *
+ * Anthropic can bind each signed thinking block to the request prefix it was
+ * generated under: system prompt, tools, and every earlier message. Compaction
+ * and tool-result pruning rewrite that prefix, so a retained signed block
+ * becomes invalid and endpoints that enforce the check reject the whole
+ * request. Each Anthropic Messages response records a digest of the context it
+ * was generated from; a later request replays its thinking only while the
+ * reconstructed prefix still has the same digest. Replay state without a
+ * digest predates this check and is treated as stale.
+ *
+ * Manual (non-adaptive) thinking requires the final assistant turn of a tool
+ * round to start with a thinking block, so that message keeps its thinking
+ * when the model does not force adaptive thinking.
+ *
+ * @module dsh-llm-pi-ai/prefix-binding
+ */
+
+import { createHash } from 'node:crypto'
+import type { Api, AssistantMessage, Context as PiContext, Model } from '@earendil-works/pi-ai'
+import type { PrefixDigests } from './replay.ts'
+
+/** Wire protocol whose thinking signatures bind to the request prefix. */
+const PREFIX_BOUND_API = 'anthropic-messages'
+
+/** Seed of every digest chain; changing the digest input requires a new seed. */
+const DIGEST_SEED = 'dsh-llm-pi-ai/prefix-binding/v1'
+
+function chain(previous: string, value: unknown): string {
+ return createHash('sha256').update(previous).update('\0').update(JSON.stringify(value)).digest('hex')
+}
+
+/** Index of the assistant message whose tool round the request continues, or -1. */
+function openToolRoundAssistant(messages: PiContext['messages']): number {
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const message = messages[index] as PiContext['messages'][number]
+ if (message.role === 'assistant') return index
+ if (message.role !== 'toolResult') return -1
+ }
+ return -1
+}
+
+/** Copy of an assistant message without its thinking blocks. */
+function withoutThinking(message: AssistantMessage): AssistantMessage {
+ return { ...message, content: message.content.filter(block => block.type !== 'thinking') }
+}
+
+/** Context whose stale thinking is removed, with the digest of the complete request. */
+export interface PrefixBoundContext {
+ /** Context to send; equal to the input when nothing was removed. */
+ context: PiContext
+ /** Digest of the sent context, recorded with the response; absent for other protocols. */
+ digest?: string
+ /** Number of replayed assistant messages whose thinking was removed. */
+ omitted: number
+}
+
+/**
+ * Remove thinking from replayed Anthropic Messages assistant messages whose
+ * request prefix changed since they were generated, or whose replay state
+ * records no prefix digest.
+ * @param context - converted request context.
+ * @param model - requested model.
+ * @param prefixDigests - recorded digests the context conversion collected.
+ * @returns the context to send, its digest for Anthropic Messages models, and the omission count.
+ */
+export function bindThinkingToPrefix(
+ context: PiContext,
+ model: Model,
+ prefixDigests: PrefixDigests,
+): PrefixBoundContext {
+ if (model.api !== PREFIX_BOUND_API) return { context, omitted: 0 }
+ const adaptive = (model as Model).compat?.forceAdaptiveThinking === true
+ const kept = adaptive ? -1 : openToolRoundAssistant(context.messages)
+ let digest = chain(DIGEST_SEED, { systemPrompt: context.systemPrompt ?? null, tools: context.tools ?? null })
+ let messages: PiContext['messages'] | undefined
+ let omitted = 0
+ for (const [index, original] of context.messages.entries()) {
+ let message = original
+ if (
+ message.role === 'assistant' && message.api === PREFIX_BOUND_API && index !== kept
+ && prefixDigests.get(message) !== digest && message.content.some(block => block.type === 'thinking')
+ ) {
+ message = withoutThinking(message)
+ messages ??= context.messages.slice()
+ messages[index] = message
+ omitted++
+ }
+ digest = chain(digest, message)
+ }
+ return { context: messages === undefined ? context : { ...context, messages }, digest, omitted }
+}
diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts
index 098f4a6301..8e5bcd3ca3 100644
--- a/packages/llm/llm-pi-ai/src/replay.ts
+++ b/packages/llm/llm-pi-ai/src/replay.ts
@@ -32,6 +32,8 @@ export interface PiAiReplayResponse {
/** Provider-native effort for historical replay; absence is preserved. */
providerThinkingLevel?: string
stopReason: AssistantMessage['stopReason']
+ /** Digest of the request context this response was generated from; see `prefix-binding.ts`. */
+ prefixDigest?: string
}
/** The validated halves of one pi-ai replay envelope. */
@@ -65,6 +67,9 @@ function emptyPiUsage(): PiUsage {
}
}
+/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */
+export type PrefixDigests = Map
+
/**
* Project a successful pi-ai response into the minimal durable replay state.
* The per-block half is index-aligned with the streamed blocks (pi-ai content
@@ -72,9 +77,10 @@ function emptyPiUsage(): PiUsage {
* removes one.
* @param message - completed native pi-ai assistant response.
* @param requestedModel - request identity stored in the assistant source; defaults to the native model.
+ * @param prefixDigest - digest of the request context, recorded when the protocol binds thinking to it.
* @returns the versioned lossless-JSON replay projection.
*/
-export function toPiReplayState(message: AssistantMessage, requestedModel = message.model): ReplayEnvelope {
+export function toPiReplayState(message: AssistantMessage, requestedModel = message.model, prefixDigest?: string): ReplayEnvelope {
const response: PiAiReplayResponse = {
kind: 'pi-ai',
version: 2,
@@ -85,6 +91,7 @@ export function toPiReplayState(message: AssistantMessage, requestedModel = mess
...message.responseId === undefined ? {} : { responseId: message.responseId },
...message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel },
stopReason: message.stopReason,
+ ...prefixDigest === undefined ? {} : { prefixDigest },
}
return {
response,
@@ -131,6 +138,7 @@ function readReplayState(value: unknown): PiAiReplayState {
if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string')
if (response['providerThinkingLevel'] !== undefined && typeof response['providerThinkingLevel'] !== 'string') return invalidReplay('providerThinkingLevel must be a string')
+ if (response['prefixDigest'] !== undefined && typeof response['prefixDigest'] !== 'string') return invalidReplay('prefixDigest must be a string')
const blocks = envelope['blocks']
if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array')
for (const [index, value] of blocks.entries()) {
@@ -184,7 +192,12 @@ function foreignAssistant(message: HarnessAssistantMessage): AssistantMessage {
}
/** Recombine durable Harness content with validated pi-ai replay metadata. */
-function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessageSource, rawState: unknown): AssistantMessage {
+function replayedAssistant(
+ message: HarnessAssistantMessage,
+ source: ModelMessageSource,
+ rawState: unknown,
+ prefixDigests?: PrefixDigests,
+): AssistantMessage {
const state = readReplayState(rawState)
if (state.response.provider !== source.provider) return invalidReplay('provider does not match assistant source')
if (state.response.model !== source.model) return invalidReplay('model does not match assistant source')
@@ -216,7 +229,7 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag
default: return invalidReplay(`block ${index} has an unsupported Harness type`)
}
})
- return {
+ const assistant: AssistantMessage = {
role: 'assistant',
content,
api: state.response.api,
@@ -229,6 +242,8 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag
stopReason: state.response.stopReason,
timestamp: 0,
}
+ if (state.response.prefixDigest !== undefined) prefixDigests?.set(assistant, state.response.prefixDigest)
+ return assistant
}
/**
@@ -242,13 +257,18 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag
* @param message - model-produced assistant content with provider, model, and optional adapter-owned replay metadata.
* @param onDegrade - called with the diagnostic reason when an unusable replay
* state falls back to provider-neutral conversion.
+ * @param prefixDigests - receives the recorded request-prefix digest of the returned message, when replay state has one.
* @returns a native pi-ai assistant message reconstructed from durable content.
*/
-export function toPiAssistant(message: HarnessAssistantMessage, onDegrade?: (reason: string) => void): AssistantMessage {
+export function toPiAssistant(
+ message: HarnessAssistantMessage,
+ onDegrade?: (reason: string) => void,
+ prefixDigests?: PrefixDigests,
+): AssistantMessage {
const source = message.source
if (source.replayState === undefined) return foreignAssistant(message)
try {
- return replayedAssistant(message, source, source.replayState)
+ return replayedAssistant(message, source, source.replayState, prefixDigests)
} catch (error: unknown) {
/* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors; the
guard keeps a future non-replay failure loud instead of silently degrading it */
diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts
index 17b7158135..a897a8e740 100644
--- a/packages/llm/llm-pi-ai/src/stream.ts
+++ b/packages/llm/llm-pi-ai/src/stream.ts
@@ -136,6 +136,7 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number)
* @param callerSignal - caller cancellation state; an aborted caller makes any
* in-band terminal error an aborted finish.
* @param requestedModel - request model identity recorded for durable replay.
+ * @param prefixDigest - request-context digest recorded for durable replay.
* @returns the harness chunks, ending with `usage` then `finish`; throws
* `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event, or
* `MALFORMED_RESPONSE` when completed tool arguments are invalid. Length-limited
@@ -146,6 +147,7 @@ export async function* toStreamChunks(
contextWindow?: number,
callerSignal?: AbortSignal,
requestedModel?: string,
+ prefixDigest?: string,
): AsyncGenerator {
// pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0
// in stream order), but we track ids per index for tool calls.
@@ -220,7 +222,7 @@ export async function* toStreamChunks(
yield {
type: 'finish',
reason: mapStopReason(event.message, contextWindow),
- replayState: toPiReplayState(event.message, requestedModel),
+ replayState: toPiReplayState(event.message, requestedModel, prefixDigest),
}
return
case 'error':
diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts
index a4b3e85955..fe6d72a2b4 100644
--- a/packages/llm/llm-pi-ai/tests/convert.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts
@@ -670,6 +670,7 @@ describe('toPiContext', () => {
['non-string response id', { ...validReplay, response: { ...validResponse, responseId: 1 } }, 'responseId must be a string'],
['non-string provider thinking level', { ...validReplay, response: { ...validResponse, providerThinkingLevel: 1 } }, 'providerThinkingLevel must be a string'],
['null provider thinking level', { ...validReplay, response: { ...validResponse, providerThinkingLevel: null } }, 'providerThinkingLevel must be a string'],
+ ['non-string prefix digest', { ...validReplay, response: { ...validResponse, prefixDigest: 1 } }, 'prefixDigest must be a string'],
['missing blocks', { response: validResponse }, 'blocks must be an array'],
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
new file mode 100644
index 0000000000..037a966d36
--- /dev/null
+++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
@@ -0,0 +1,178 @@
+/** Replayed Anthropic thinking is bound to the request prefix it was generated under. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import LlmRuntime, { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { Message, ToolCallId } from '@deepseek-ai/dsh-llm'
+import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
+import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
+import { resolveProfiles } from '../src/config.ts'
+import { memoryAuth } from './auth-double.ts'
+import { assemble } from './assemble.ts'
+import { anthropicFrame, closeMockServers, mockServer } from './mock-server.ts'
+
+const contexts: Context[] = []
+afterEach(async () => {
+ await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+ await closeMockServers()
+ vi.unstubAllEnvs()
+})
+
+const tool = { name: 'lookup', description: 'Look up a value.', parameters: { type: 'object', properties: { key: { type: 'number' } } } }
+
+/** One Anthropic response: signed and redacted thinking, then a tool call or a reply. */
+function frames(signature: string, toolUse: boolean): ReturnType[] {
+ const blocks = [
+ { type: 'thinking', thinking: `thinking ${signature}`, signature },
+ { type: 'redacted_thinking', data: `redacted-${signature}` },
+ toolUse ? { type: 'tool_use', id: `toolu_${signature}`, name: 'lookup', input: { key: 1 } } : { type: 'text', text: 'done' },
+ ]
+ return [
+ anthropicFrame({ type: 'message_start', message: {
+ id: `msg_${signature}`, type: 'message', role: 'assistant', model: 'model', content: [],
+ stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 1 },
+ } }),
+ ...blocks.flatMap((content_block, index) => [
+ anthropicFrame({ type: 'content_block_start', index, content_block }),
+ anthropicFrame({ type: 'content_block_stop', index }),
+ ]),
+ anthropicFrame({ type: 'message_delta', delta: { stop_reason: toolUse ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }),
+ anthropicFrame({ type: 'message_stop' }),
+ ]
+}
+
+async function setup(api: 'anthropic-messages' | 'openai-completions', script: Parameters[0], adaptive = true) {
+ vi.stubEnv('PREFIX_BINDING_KEY', 'test-key')
+ const server = await mockServer(script)
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(LlmRuntime)
+ await ctx.plugin(LlmPiAi, { providers: {
+ route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model' }],
+ ...api === 'anthropic-messages' ? { compat: { forceAdaptiveThinking: adaptive } } : {} },
+ } })
+ const run = async (messages: Message[]) => {
+ const result = await assemble(ctx, { provider: 'route', model: 'model', messages, tools: [tool], system: 'system' })
+ return JSON.parse(JSON.stringify(result.message)) as Message
+ }
+ return { server, run }
+}
+
+function user(text: string): Message {
+ return createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] })
+}
+
+function result(callId: ToolCallId, text: string): Message {
+ return JSON.parse(JSON.stringify(createToolResultMessage({ callId, content: [{ type: 'text', text }], isError: false }))) as Message
+}
+
+function callId(message: Message): ToolCallId {
+ const call = message.content.find(block => block.type === 'tool-call')
+ if (call === undefined) throw new Error('expected a tool call')
+ return call.id
+}
+
+function wire(request: unknown): string {
+ return JSON.stringify((request as { messages: unknown }).messages)
+}
+
+describe('Anthropic thinking prefix binding', () => {
+ it('keeps thinking while its prefix is unchanged and omits it after the prefix is rewritten', async () => {
+ const { server, run } = await setup('anthropic-messages', [
+ { events: frames('first', true) },
+ { events: frames('second', true) },
+ { events: frames('third', false) },
+ { events: frames('fourth', false) },
+ { events: frames('fifth', false) },
+ ])
+ const first = await run([user('Look up the value.')])
+ expect(first.source).toMatchObject({ replayState: { response: { prefixDigest: expect.stringMatching(/^[0-9a-f]{64}$/) } } })
+ const second = await run([user('Look up the value.'), first, result(callId(first), 'large output')])
+ expect(wire(server.requests[1])).toContain('"signature":"first"')
+ expect(wire(server.requests[1])).toContain('redacted-first')
+
+ // Pruning the first tool result rewrites the second response's prefix but not the first one's.
+ const tail = [first, result(callId(first), 'pruned'), second, result(callId(second), 'value')]
+ const third = await run([user('Look up the value.'), ...tail])
+ expect(wire(server.requests[2])).toContain('"signature":"first"')
+ expect(wire(server.requests[2])).not.toContain('"signature":"second"')
+ expect(wire(server.requests[2])).not.toContain('redacted-second')
+ expect(wire(server.requests[2])).toContain('toolu_second')
+
+ // Compaction replaces the head: every retained response loses its thinking, tool calls stay paired.
+ const compacted = [user(''), ...tail, third, user('Continue.')]
+ const fourth = await run(compacted)
+ const request = wire(server.requests[3])
+ for (const stale of ['"signature":"first"', 'redacted-first', '"signature":"second"', '"signature":"third"']) {
+ expect(request).not.toContain(stale)
+ }
+ expect(request).toContain('toolu_first')
+ expect(request).toContain('toolu_second')
+
+ // A response generated on the compacted prefix replays its own thinking.
+ await run([...compacted, fourth, user('Again.')])
+ expect(wire(server.requests[4])).toContain('"signature":"fourth"')
+ expect(wire(server.requests[4])).not.toContain('"signature":"third"')
+ })
+
+ it('treats a changed system prompt as a changed prefix', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }])
+ const first = await run([user('Look up the value.')])
+ const ctx = contexts[0] as Context
+ await assemble(ctx, {
+ provider: 'route', model: 'model', tools: [tool], system: 'changed system',
+ messages: [user('Look up the value.'), first, result(callId(first), 'value')],
+ })
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ expect(wire(server.requests[1])).toContain('toolu_first')
+ })
+
+ it('omits thinking whose replay state records no prefix digest', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }])
+ const first = await run([user('Look up the value.')])
+ const replayState = (first.source as { replayState: { response: Record } }).replayState
+ delete replayState.response['prefixDigest']
+ await run([user('Look up the value.'), first, result(callId(first), 'value')])
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ expect(wire(server.requests[1])).toContain('toolu_first')
+ })
+
+ it('keeps stale thinking only on the open tool round of a manual-thinking model', async () => {
+ const { server, run } = await setup('anthropic-messages', [
+ { events: frames('first', true) },
+ { events: frames('second', true) },
+ { events: frames('third', false) },
+ ], false)
+ const first = await run([user('Look up the value.')])
+ const second = await run([user('Look up the value.'), first, result(callId(first), 'value')])
+ await run([user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')])
+ expect(wire(server.requests[2])).not.toContain('"signature":"first"')
+ expect(wire(server.requests[2])).toContain('"signature":"second"')
+ })
+
+ it('omits stale thinking without an omission observer', async () => {
+ const { run } = await setup('anthropic-messages', [{ events: frames('first', true) }])
+ const first = await run([user('Look up the value.')])
+ const server = await mockServer([{ events: frames('second', false) }])
+ const adapter = new PiAiAdapter({
+ profiles: () => resolveProfiles({ route: {
+ api: 'anthropic-messages', baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none',
+ models: [{ id: 'model' }], compat: { forceAdaptiveThinking: true },
+ } }),
+ resolveApiKey: () => Promise.resolve('test-key'),
+ auth: memoryAuth(),
+ })
+ const messages = [user(''), first, result(callId(first), 'value')]
+ for await (const _chunk of adapter.stream({ provider: 'route', model: 'model', messages, tools: [tool], system: 'system' })) { /* drain */ }
+ expect(wire(server.requests[0])).not.toContain('"signature":"first"')
+ })
+
+ it('records no prefix digest for protocols without prefix binding', async () => {
+ const { run } = await setup('openai-completions', [{ events: [
+ JSON.stringify({ choices: [{ index: 0, delta: { role: 'assistant', content: 'reply' }, finish_reason: null }] }),
+ JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 1 } }),
+ '[DONE]',
+ ] }])
+ const reply = await run([user('Hello.')])
+ expect((reply.source as { replayState: { response: object } }).replayState.response).not.toHaveProperty('prefixDigest')
+ })
+})
From 052c6c9c536aa4b01dbeb14c02f0b1d7f214cbf1 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 01:37:17 +0800
Subject: [PATCH 007/106] test(llm-pi-ai): type the prefix digest matcher
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
index 037a966d36..f3c108c027 100644
--- a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
@@ -85,7 +85,8 @@ describe('Anthropic thinking prefix binding', () => {
{ events: frames('fifth', false) },
])
const first = await run([user('Look up the value.')])
- expect(first.source).toMatchObject({ replayState: { response: { prefixDigest: expect.stringMatching(/^[0-9a-f]{64}$/) } } })
+ const prefixDigest: unknown = expect.stringMatching(/^[0-9a-f]{64}$/)
+ expect(first.source).toMatchObject({ replayState: { response: { prefixDigest } } })
const second = await run([user('Look up the value.'), first, result(callId(first), 'large output')])
expect(wire(server.requests[1])).toContain('"signature":"first"')
expect(wire(server.requests[1])).toContain('redacted-first')
From 5824871706c500d4f15bb0a4be0791c0f7f34efa Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 01:52:38 +0800
Subject: [PATCH 008/106] fix(llm-pi-ai): report thinking omissions on every
Anthropic request
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/src/adapter.ts | 9 +++++----
packages/llm/llm-pi-ai/src/index.ts | 2 +-
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index 0f8c53f25d..dc27de4535 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -129,9 +129,10 @@ export interface PiAiAdapterOptions {
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
/**
- * Observe one request omitting replayed Anthropic thinking whose request
- * prefix changed or was never recorded; stale messages stay omitted, so
- * every later request of the conversation reports them again.
+ * Observe how many replayed assistant messages one request sent without
+ * their Anthropic thinking because the request prefix changed or was never
+ * recorded; called for every request, including with a zero count. Stale
+ * messages stay omitted, so later requests of the conversation count them again.
*/
onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void
}
@@ -465,7 +466,7 @@ export class PiAiAdapter extends LlmAdapter {
},
}, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests)
- if (omitted > 0) this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
+ this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index a671b7229a..9fa3f0c7ac 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -253,7 +253,7 @@ export function apply(ctx: Context, config: Config): void {
)
},
onThinkingOmitted: ({ provider, model, count }) => {
- ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
+ if (count > 0) ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
},
})
// Independent of the route set: signing in is what makes a route worth
From 018689a59b9ae52930622c8ea6aa96672074217c Mon Sep 17 00:00:00 2001
From: Turtle
Date: Fri, 30 Oct 2026 02:38:47 +0800
Subject: [PATCH 009/106] fix(llm-pi-ai): narrow Anthropic thinking omission to
signed blocks
Keep stale thinking on the open tool round only when the request enables
manual thinking and the round continues with tool results; omit only
signed or redacted thinking; report omissions only when there are any;
own PrefixDigests in prefix-binding; document consumer gaps under Known
Limitations.
Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com>
---
packages/llm/llm-pi-ai/README.i18n.yaml | 8 +--
packages/llm/llm-pi-ai/README.md | 4 +-
packages/llm/llm-pi-ai/README.zh.md | 4 +-
packages/llm/llm-pi-ai/src/adapter.ts | 17 +++--
packages/llm/llm-pi-ai/src/context.ts | 2 +-
packages/llm/llm-pi-ai/src/index.ts | 2 +-
packages/llm/llm-pi-ai/src/prefix-binding.ts | 71 ++++++++++++-------
packages/llm/llm-pi-ai/src/replay.ts | 4 +-
.../llm-pi-ai/tests/prefix-binding.spec.ts | 58 +++++++++++++--
9 files changed, 119 insertions(+), 51 deletions(-)
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index a1dbdb1b1d..7c68e687ce 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -60,8 +60,8 @@
en: 477d277e9ac35c8f
zh: 96870ca333fb6510
/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary:
- en: 507ceb2856849646
- zh: 4f54925b52a1c190
+ en: e68f92d78150d341
+ zh: 45850c739a7329d3
/deepseek-ai-dsh-llm-pi-ai/further-exploration:
en: a2a2ccdeae59d408
zh: c47ee091aa90ad91
@@ -93,8 +93,8 @@
en: b6bd570c0159ba08
zh: 7d2d91eea6a8d830
/deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work:
- en: 1c54b949c81df84f
- zh: b894d01aa3e6afc1
+ en: 1664f55c85478298
+ zh: 1fa4e07e49cc6cb6
/deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note:
en: 3678dd0f6642c2a5
zh: 75cbdd064cc64282
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 68cf9c77d3..b70d1b12b5 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -192,7 +192,7 @@ The `pre-commit` hook runs `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and uses it to retain same-model signatures; provider-reported response models remain diagnostic metadata. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks. Protocol conversion operates on request-local copies. Switching from A to B and back to A preserves the stored content, including empty text blocks, tool ids, original block order, and native replay metadata; requests to a different provider, API, or model omit unsupported signatures and reasoning blocks. Degraded replay without native metadata also omits reasoning. Valid native replay retains the stored reasoning when the original model resumes.
-Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's thinking and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text and tool calls remain, and omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues keeps its thinking. The digest covers the pi-ai context only; switching authentication or compat settings that change the wire request mid-conversation is not detected.
+Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's signed and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text, tool calls, and unsigned thinking remain; a response that held only signed thinking is no longer sent. Omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when a request enables thinking and the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues with tool results keeps its thinking.
@@ -268,6 +268,8 @@ These limits define where the adapter stops and future work begins. They are cur
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers.
- **Retry policy is provider-owned, not an SDK retry** — pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
- **Streamed JSON tool-call arguments avoid per-delta reparsing** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@1.3.0.patch`](../../../patches/@earendil-works__pi-ai@1.3.0.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Completion events supply parsed arguments; Responses may parse at both argument completion and output-item completion. This adapter reads only delta strings and finalized arguments. Re-apply or retire the patch on every pi-ai upgrade.
+- **Stale Anthropic thinking is omitted even where the endpoint would accept it** — prefix checks depend on the model, the account, and the request, which the adapter cannot observe, so every prefix change and every replay state without `prefixDigest` costs the model that reasoning, and Anthropic regenerates it as output tokens. The digest covers the pi-ai context objects, not the wire body: switching authentication or wire-changing compat settings mid-conversation is not detected, a pi-ai upgrade that changes Anthropic message serialization requires a new `DIGEST_SEED` in [`src/prefix-binding.ts`](src/prefix-binding.ts), and any change to how this package builds those objects drops recorded thinking once.
+- **A manual-thinking tool round that spans a prefix change can still be rejected** — its open assistant message must keep its stale thinking to satisfy manual thinking, so an endpoint that enforces the prefix check rejects each request until a user message ends the round. Models that enforce the check use adaptive thinking in the installed catalog.
- **Gemini tool-call thought signatures are patched into Chat Completions** — the same patch reads `tool_calls[].extra_content.google.thought_signature` from a streamed OpenAI-compatible response into the tool call's replay signature and sends it back as `extra_content` when the same model resumes (upstream [earendil-works/pi#10157](https://github.com/earendil-works/pi/issues/10157)); unpatched, Gemini rejects the next step of a tool-calling turn with HTTP 400. Encrypted OpenRouter reasoning details keep their `reasoning_details` replay.
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index 620fd428f6..69bcd52afa 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -192,7 +192,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts
成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并用它保留同模型签名;提供方报告的响应模型仅作为诊断元数据。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。 协议转换仅作用于请求副本。从 A 切换到 B 再切回 A 时,存储的内容保持完整,包括空文本块、工具 id、原始块顺序和原生回放元数据;发给不同提供方、API 或模型的请求会省略不受支持的签名和推理块。缺少原生元数据的降级回放也会省略推理。切回原模型时,有效的原生回放会保留已存储的推理。
-Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本和工具调用保留,已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当模型的 compat 未设置 `forceAdaptiveThinking` 时,请求所延续的工具轮次对应的 assistant 消息会保留其 thinking。摘要只覆盖 pi-ai 上下文;对话中途切换认证或 compat 设置而改变线上请求时,不会被检测到。
+Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的已签名 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本、工具调用和未签名 thinking 保留;只含已签名 thinking 的响应不再发送。已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当请求启用 thinking 且模型的 compat 未设置 `forceAdaptiveThinking` 时,请求以工具结果延续的工具轮次对应的 assistant 消息会保留其 thinking。
@@ -268,6 +268,8 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish
- **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。
- **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent(智能体)步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。
- **流式 JSON 工具调用参数避免逐 delta 重复解析**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@1.3.0.patch`](../../../patches/@earendil-works__pi-ai@1.3.0.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。完成事件提供解析后的参数;Responses 可能在参数完成和输出项完成时分别解析。本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。
+- **即使端点会接受,过期的 Anthropic thinking 也会被省略**——前缀检查取决于模型、账户和请求,而适配器无法观测这些因素,因此每次前缀变化以及每个不含 `prefixDigest` 的回放状态都会让模型失去这部分推理,Anthropic 会以输出 token 重新生成它。摘要覆盖 pi-ai 上下文对象,而不是线上请求体:对话中途切换认证或会改变线上请求的 compat 设置不会被检测到;改变 Anthropic 消息序列化的 pi-ai 升级需要在 [`src/prefix-binding.ts`](src/prefix-binding.ts) 中使用新的 `DIGEST_SEED`;本包构建这些对象的方式发生任何变化,都会使已记录的 thinking 被丢弃一次。
+- **跨越前缀变化的手动思考工具轮次仍可能被拒绝**——其未结束的 assistant 消息必须保留过期的 thinking 以满足手动思考要求,因此执行前缀检查的端点会拒绝每个请求,直到一条用户消息结束该轮次。在已安装的目录中,执行此检查的模型使用自适应思考。
- **Chat Completions 中的 Gemini 工具调用思考签名由补丁保留**——同一补丁把 OpenAI 兼容流式响应里的 `tool_calls[].extra_content.google.thought_signature` 读入工具调用的重放签名,并在同一模型继续时以 `extra_content` 发回(上游 [earendil-works/pi#10157](https://github.com/earendil-works/pi/issues/10157));未打补丁时,Gemini 会以 HTTP 400 拒绝工具调用轮次的下一步。OpenRouter 的加密推理详情仍通过 `reasoning_details` 重放。
diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
index dc27de4535..be41fc2242 100644
--- a/packages/llm/llm-pi-ai/src/adapter.ts
+++ b/packages/llm/llm-pi-ai/src/adapter.ts
@@ -63,7 +63,7 @@ import { requestImageTarget, toPiContext } from './context.ts'
import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts'
import { toStreamChunks } from './stream.ts'
import { bindThinkingToPrefix } from './prefix-binding.ts'
-import type { PrefixDigests } from './replay.ts'
+import type { PrefixDigests } from './prefix-binding.ts'
import type { PiAiRequestHeaderContext } from './types.ts'
/**
@@ -129,10 +129,10 @@ export interface PiAiAdapterOptions {
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
/**
- * Observe how many replayed assistant messages one request sent without
- * their Anthropic thinking because the request prefix changed or was never
- * recorded; called for every request, including with a zero count. Stale
- * messages stay omitted, so later requests of the conversation count them again.
+ * Observe one request that sent replayed assistant messages without their
+ * signed Anthropic thinking because the request prefix changed or was never
+ * recorded. Stale messages stay omitted, so later requests of the
+ * conversation report them again.
*/
onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void
}
@@ -465,8 +465,11 @@ export class PiAiAdapter extends LlmAdapter {
maxBytes: profile.requestImageMaxBytes,
},
}, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests)
- const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests)
- this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted })
+ const manualThinking = model.reasoning && reasoning !== undefined && reasoning !== 'off'
+ && (model as Model<'anthropic-messages'>).compat?.forceAdaptiveThinking !== true
+ const { context, digest } = bindThinkingToPrefix(converted, model.api, prefixDigests, manualThinking, (count) => {
+ this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count })
+ })
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts
index 3d5f9d91b7..91e9e1c35f 100644
--- a/packages/llm/llm-pi-ai/src/context.ts
+++ b/packages/llm/llm-pi-ai/src/context.ts
@@ -16,7 +16,7 @@ import type {
} from '@deepseek-ai/dsh-attachment'
import type { Context as PiContext, ImageContent, Message as PiMessage, SystemMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
import { toPiAssistant } from './replay.ts'
-import type { PrefixDigests } from './replay.ts'
+import type { PrefixDigests } from './prefix-binding.ts'
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment'
import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts'
diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts
index 9fa3f0c7ac..a671b7229a 100644
--- a/packages/llm/llm-pi-ai/src/index.ts
+++ b/packages/llm/llm-pi-ai/src/index.ts
@@ -253,7 +253,7 @@ export function apply(ctx: Context, config: Config): void {
)
},
onThinkingOmitted: ({ provider, model, count }) => {
- if (count > 0) ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
+ ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`)
},
})
// Independent of the route set: signing in is what makes a route worth
diff --git a/packages/llm/llm-pi-ai/src/prefix-binding.ts b/packages/llm/llm-pi-ai/src/prefix-binding.ts
index d5ed0451df..9ca530c339 100644
--- a/packages/llm/llm-pi-ai/src/prefix-binding.ts
+++ b/packages/llm/llm-pi-ai/src/prefix-binding.ts
@@ -6,44 +6,55 @@
* and tool-result pruning rewrite that prefix, so a retained signed block
* becomes invalid and endpoints that enforce the check reject the whole
* request. Each Anthropic Messages response records a digest of the context it
- * was generated from; a later request replays its thinking only while the
- * reconstructed prefix still has the same digest. Replay state without a
+ * was generated from; a later request replays its signed thinking only while
+ * the reconstructed prefix still has the same digest. Replay state without a
* digest predates this check and is treated as stale.
*
- * Manual (non-adaptive) thinking requires the final assistant turn of a tool
- * round to start with a thinking block, so that message keeps its thinking
- * when the model does not force adaptive thinking.
+ * The digest covers the pi-ai context objects this package builds, including
+ * fields Anthropic never receives. Any change to their construction
+ * invalidates every recorded digest once; a pi-ai upgrade that changes how
+ * Anthropic messages are serialized without changing those objects requires a
+ * new {@link DIGEST_SEED}.
*
* @module dsh-llm-pi-ai/prefix-binding
*/
import { createHash } from 'node:crypto'
-import type { Api, AssistantMessage, Context as PiContext, Model } from '@earendil-works/pi-ai'
-import type { PrefixDigests } from './replay.ts'
+import type { Api, AssistantMessage, Context as PiContext } from '@earendil-works/pi-ai'
+
+/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */
+export type PrefixDigests = Map
/** Wire protocol whose thinking signatures bind to the request prefix. */
const PREFIX_BOUND_API = 'anthropic-messages'
-/** Seed of every digest chain; changing the digest input requires a new seed. */
+/** Seed of every digest chain; changing the digest input or its serialization requires a new seed. */
const DIGEST_SEED = 'dsh-llm-pi-ai/prefix-binding/v1'
function chain(previous: string, value: unknown): string {
return createHash('sha256').update(previous).update('\0').update(JSON.stringify(value)).digest('hex')
}
-/** Index of the assistant message whose tool round the request continues, or -1. */
+/** Index of the assistant message whose tool round the request continues with tool results, or -1. */
function openToolRoundAssistant(messages: PiContext['messages']): number {
+ let results = 0
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index] as PiContext['messages'][number]
- if (message.role === 'assistant') return index
+ if (message.role === 'assistant') return results > 0 ? index : -1
if (message.role !== 'toolResult') return -1
+ results++
}
return -1
}
-/** Copy of an assistant message without its thinking blocks. */
-function withoutThinking(message: AssistantMessage): AssistantMessage {
- return { ...message, content: message.content.filter(block => block.type !== 'thinking') }
+/** Whether a thinking block carries a signature or redacted payload that binds it to its prefix. */
+function signed(block: AssistantMessage['content'][number]): boolean {
+ return block.type === 'thinking' && (block.redacted === true || (block.thinkingSignature ?? '').length > 0)
+}
+
+/** Copy of an assistant message without its signed thinking blocks. */
+function withoutSignedThinking(message: AssistantMessage): AssistantMessage {
+ return { ...message, content: message.content.filter(block => !signed(block)) }
}
/** Context whose stale thinking is removed, with the digest of the complete request. */
@@ -52,27 +63,31 @@ export interface PrefixBoundContext {
context: PiContext
/** Digest of the sent context, recorded with the response; absent for other protocols. */
digest?: string
- /** Number of replayed assistant messages whose thinking was removed. */
- omitted: number
}
/**
- * Remove thinking from replayed Anthropic Messages assistant messages whose
- * request prefix changed since they were generated, or whose replay state
- * records no prefix digest.
+ * Remove signed thinking from replayed Anthropic Messages assistant messages
+ * whose request prefix changed since they were generated, or whose replay
+ * state records no prefix digest. Manual thinking requires the final
+ * assistant turn of a tool round to start with a thinking block, so with
+ * `manualThinking` the assistant message whose tool round the request
+ * continues keeps its thinking.
* @param context - converted request context.
- * @param model - requested model.
+ * @param api - wire protocol of the requested model.
* @param prefixDigests - recorded digests the context conversion collected.
- * @returns the context to send, its digest for Anthropic Messages models, and the omission count.
+ * @param manualThinking - whether the request enables budget-based rather than adaptive thinking.
+ * @param onOmitted - called once with the number of messages whose thinking was removed, when there are any.
+ * @returns the context to send and, for Anthropic Messages, its digest.
*/
export function bindThinkingToPrefix(
context: PiContext,
- model: Model,
+ api: Api,
prefixDigests: PrefixDigests,
+ manualThinking: boolean,
+ onOmitted: (count: number) => void,
): PrefixBoundContext {
- if (model.api !== PREFIX_BOUND_API) return { context, omitted: 0 }
- const adaptive = (model as Model).compat?.forceAdaptiveThinking === true
- const kept = adaptive ? -1 : openToolRoundAssistant(context.messages)
+ if (api !== PREFIX_BOUND_API) return { context }
+ const kept = manualThinking ? openToolRoundAssistant(context.messages) : -1
let digest = chain(DIGEST_SEED, { systemPrompt: context.systemPrompt ?? null, tools: context.tools ?? null })
let messages: PiContext['messages'] | undefined
let omitted = 0
@@ -80,14 +95,16 @@ export function bindThinkingToPrefix(
let message = original
if (
message.role === 'assistant' && message.api === PREFIX_BOUND_API && index !== kept
- && prefixDigests.get(message) !== digest && message.content.some(block => block.type === 'thinking')
+ && prefixDigests.get(message) !== digest && message.content.some(signed)
) {
- message = withoutThinking(message)
+ message = withoutSignedThinking(message)
messages ??= context.messages.slice()
messages[index] = message
omitted++
}
digest = chain(digest, message)
}
- return { context: messages === undefined ? context : { ...context, messages }, digest, omitted }
+ if (messages === undefined) return { context, digest }
+ onOmitted(omitted)
+ return { context: { ...context, messages }, digest }
}
diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts
index 8e5bcd3ca3..ba1be42e14 100644
--- a/packages/llm/llm-pi-ai/src/replay.ts
+++ b/packages/llm/llm-pi-ai/src/replay.ts
@@ -11,6 +11,7 @@
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage as HarnessAssistantMessage, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
import type { Api, AssistantMessage, ToolCall, Usage as PiUsage } from '@earendil-works/pi-ai'
+import type { PrefixDigests } from './prefix-binding.ts'
/** Per-block half of the pi-ai replay envelope, one entry per content block. */
export type PiAiReplayBlock =
@@ -67,9 +68,6 @@ function emptyPiUsage(): PiUsage {
}
}
-/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */
-export type PrefixDigests = Map
-
/**
* Project a successful pi-ai response into the minimal durable replay state.
* The per-block half is index-aligned with the streamed blocks (pi-ai content
diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
index f3c108c027..ac003107a2 100644
--- a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts
@@ -21,11 +21,14 @@ const tool = { name: 'lookup', description: 'Look up a value.', parameters: { ty
/** One Anthropic response: signed and redacted thinking, then a tool call or a reply. */
function frames(signature: string, toolUse: boolean): ReturnType[] {
- const blocks = [
+ return response(signature, [
{ type: 'thinking', thinking: `thinking ${signature}`, signature },
{ type: 'redacted_thinking', data: `redacted-${signature}` },
toolUse ? { type: 'tool_use', id: `toolu_${signature}`, name: 'lookup', input: { key: 1 } } : { type: 'text', text: 'done' },
- ]
+ ], toolUse)
+}
+
+function response(signature: string, blocks: Record[], toolUse = false): ReturnType[] {
return [
anthropicFrame({ type: 'message_start', message: {
id: `msg_${signature}`, type: 'message', role: 'assistant', model: 'model', content: [],
@@ -40,14 +43,15 @@ function frames(signature: string, toolUse: boolean): ReturnType[0], adaptive = true) {
+async function setup(api: 'anthropic-messages' | 'openai-completions', script: Parameters[0], adaptive = true, manual = false) {
vi.stubEnv('PREFIX_BINDING_KEY', 'test-key')
const server = await mockServer(script)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmPiAi, { providers: {
- route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model' }],
+ route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model', ...manual ? { reasoningEfforts: { high: 'high' } } : {} }],
+ ...manual ? { reasoning: 'high' as const } : {},
...api === 'anthropic-messages' ? { compat: { forceAdaptiveThinking: adaptive } } : {} },
} })
const run = async (messages: Message[]) => {
@@ -137,17 +141,59 @@ describe('Anthropic thinking prefix binding', () => {
expect(wire(server.requests[1])).toContain('toolu_first')
})
- it('keeps stale thinking only on the open tool round of a manual-thinking model', async () => {
+ it('keeps stale thinking only on the open tool round of a manual-thinking request', async () => {
const { server, run } = await setup('anthropic-messages', [
{ events: frames('first', true) },
{ events: frames('second', true) },
{ events: frames('third', false) },
- ], false)
+ { events: frames('fourth', false) },
+ ], false, true)
const first = await run([user('Look up the value.')])
const second = await run([user('Look up the value.'), first, result(callId(first), 'value')])
await run([user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')])
expect(wire(server.requests[2])).not.toContain('"signature":"first"')
expect(wire(server.requests[2])).toContain('"signature":"second"')
+ expect(server.requests[2]).toMatchObject({ thinking: { type: 'enabled' } })
+
+ // Once a user message closes the round, the formerly kept thinking is stale too.
+ const compacted = [user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')]
+ await run([...compacted, user('Continue.')])
+ expect(wire(server.requests[3])).not.toContain('"signature":"second"')
+ })
+
+ it('sends an empty manual-thinking history unchanged', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', false) }], false, true)
+ await run([])
+ expect(server.requests[0]).toMatchObject({ messages: [] })
+ })
+
+ it('omits stale manual thinking from a final assistant message without tool results', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', false) }, { events: frames('second', false) }], false, true)
+ const first = await run([user('Answer.')])
+ await run([user(''), first])
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ })
+
+ it('omits stale thinking of the open tool round when the request disables thinking', async () => {
+ const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }], false)
+ const first = await run([user('Look up the value.')])
+ await run([user(''), first, result(callId(first), 'value')])
+ expect(wire(server.requests[1])).not.toContain('"signature":"first"')
+ })
+
+ it('drops a thinking-only message and keeps unsigned thinking', async () => {
+ const { server, run } = await setup('anthropic-messages', [
+ { events: response('only', [{ type: 'thinking', thinking: 'thinking only', signature: 'only' }]) },
+ { events: response('unsigned', [{ type: 'thinking', thinking: 'unsigned thinking', signature: '' }, { type: 'text', text: 'reply' }]) },
+ { events: frames('third', false) },
+ ])
+ const only = await run([user('Think.')])
+ const unsigned = await run([user('Think.'), only, user('Answer.')])
+ await run([user(''), only, user('Answer.'), unsigned, user('Again.')])
+ const messages = (server.requests[2] as { messages: { role: string }[] }).messages
+ expect(messages.map(message => message.role)).toEqual(['user', 'user', 'assistant', 'user'])
+ expect(wire(server.requests[2])).not.toContain('thinking only')
+ expect(wire(server.requests[2])).toContain('unsigned thinking')
})
it('omits stale thinking without an omission observer', async () => {
From db9de7da60076fed34b02ad4681d02606e52fe2b Mon Sep 17 00:00:00 2001
From: Turtle
Date: Mon, 2 Nov 2026 18:03:17 +0800
Subject: [PATCH 010/106] refactor(ui-settings-models): build retry-policy
fields from the provider schema
---
.../mobile-collapsed.expected.md | 6 +
.../mobile-expanded.expected.md | 6 +
apps/web/tests/models-settings.e2e.ts | 6 +-
.../ui-settings-models/README.i18n.yaml | 8 +-
packages/client/ui-settings-models/README.md | 4 +-
.../client/ui-settings-models/README.zh.md | 4 +-
.../src/client/ProviderEditor.tsx | 6 +-
.../src/client/RetryPolicyEditor.tsx | 327 ++++++++++--------
.../ui-settings-models/src/client/locales.ts | 64 ++--
.../tests/retry-policy.client.spec.tsx | 90 ++++-
packages/llm/llm/src/retry-policy.ts | 8 +-
packages/llm/llm/tests/retry-policy.spec.ts | 16 +
12 files changed, 352 insertions(+), 193 deletions(-)
diff --git a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md
index 46babcd11e..2f573f6e53 100644
--- a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md
+++ b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md
@@ -36,6 +36,12 @@
- option "OpenAI Chat Completions"
- option "OpenAI Responses"
- option "Anthropic Messages" [selected]
+ - group "重试策略":
+ - text: 重试策略
+ - combobox "重试策略":
+ - option "默认(仅临时错误)" [selected]
+ - option "仅临时错误"
+ - option "所有错误"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
diff --git a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md
index b1f780f9b3..5e0855c653 100644
--- a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md
+++ b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md
@@ -36,6 +36,12 @@
- option "OpenAI Chat Completions"
- option "OpenAI Responses"
- option "Anthropic Messages" [selected]
+ - group "重试策略":
+ - text: 重试策略
+ - combobox "重试策略":
+ - option "默认(仅临时错误)" [selected]
+ - option "仅临时错误"
+ - option "所有错误"
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 67dc2773fb..c59d3cd2e6 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -641,8 +641,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await toggle.click()
expect(await name.inputValue()).toBe('手机上的完整显示名称')
const expandedName = await name.boundingBox()
- if (expandedName === null) throw new Error('Expanded model name is not visible')
- expect(expandedName.y).toBeGreaterThanOrEqual(compactId.y + compactId.height)
+ // Expanding can scroll the dialog, so the ID is measured again in the same frame.
+ const expandedId = await id.boundingBox()
+ if (expandedName === null || expandedId === null) throw new Error('Expanded model fields are not visible')
+ expect(expandedName.y).toBeGreaterThanOrEqual(expandedId.y + expandedId.height)
expect(expandedName.width).toBeGreaterThan(compactId.width)
expect(await dialog.getByRole('region', { name: '模型目录', exact: true })
.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index 18d6e519e0..ff9ccb9f3e 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -21,8 +21,8 @@
en: d771da09da774692
zh: 9566b3c0e95da8a6
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
- en: a9ff7675aa8a5c7c
- zh: 85d61b45635c9f7a
+ en: bf0cf1707b319061
+ zh: 30f1fe4e655723b3
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: c0b04b2b11af60d3
zh: d195eec281e445cb
@@ -36,8 +36,8 @@
en: a87afbec5e339b36
zh: 6348c81af7e650e1
/deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/validation:
- en: 1695ec7430ad88e1
- zh: 1df96b8ae82c2e26
+ en: d469e496518d44c5
+ zh: e71938ef7502a20c
/deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/concurrency-and-credentials:
en: 6e04c00818f014cd
zh: 2d57f8d8944e67ed
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index 0f1d2d6146..84abb42ce6 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits.
-The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
+The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses, such as a fractional retry count, a delay that is not positive, or a jitter ratio above 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or the schema's first mode when none is inherited, and Apply saves that mode. Switching modes keeps only the fields the new mode declares. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
@@ -93,7 +93,7 @@ The page never holds a full settings section: it holds only the REDACTED descrip
### Validation
-A typed API key is judged on its own field: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A drafted `retryPolicy` passes the checks of `resolveRetryPolicy` in `@deepseek-ai/dsh-llm`, mirrored for the same reason, because `llm-deepseek` only logs a warning for a live policy it refuses. Switching to always mode keeps the transient-only fields, which the adapter accepts and ignores, except a retry count that is not a number and could no longer be corrected. The card writes the policy field by field, so an unchanged inherited field stays inherited. 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 typed API key is judged on its own field: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. The retry fields come from the `retryPolicy` schema the namespace publishes: each union branch is a mode, number fields become inputs whose integer step, minimum, and maximum are checked from the schema, string lists become the code picker, nested objects become folds, and fields of other types are not shown. The labels, hints, messages, and code names are copy owned by this page; an unlabelled field shows its schema key. The two rules `resolveRetryPolicy` in `@deepseek-ai/dsh-llm` enforces beyond any field bound, an initial delay at most the maximum delay and no repeated code, are mirrored for the same reason, because `llm-deepseek` only logs a warning for a live policy it refuses. The card writes the policy field by field, so an unchanged inherited field stays inherited. 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.
### Concurrency and credentials
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index efeb3decb3..8b51476f2a 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -55,7 +55,7 @@ API 密钥输入框以掩码显示已保存的密钥,并通过 `autocomplete="
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。
-同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
+同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用,例如小数重试次数、非正延迟或大于 1 的抖动比例。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示 schema 中的第一个模式,应用时会保存该模式。切换模式时只保留新模式声明的字段。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
@@ -93,7 +93,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS
### 校验
-键入的 API 密钥按其自身字段判定:去除首尾空白后必须非空,且每个字符都必须是可打印 ASCII(`[\x21-\x7E]`),这正是 HTTP 头值能够携带的字符集——与 `@deepseek-ai/dsh-llm` 中的 `normalizeApiKey` 互为镜像,此处复刻是因为源平面拆分禁止导入它。与粘贴的 `NAME=value` 环境行一致或包裹在匹配引号内的值,会作为同样的格式失败被拒绝。空 id、重复 id、空显式名称以及不可读、非正数或小数的容量都会在任何写入之前失败。草稿中的 `retryPolicy` 须通过 `@deepseek-ai/dsh-llm` 中 `resolveRetryPolicy` 的检查,出于同样原因在此复刻,因为 `llm-deepseek` 对它拒绝的实时策略只记录警告。切换到重试所有错误模式时保留仅临时错误模式使用的字段,适配器接受并忽略它们;但不是数字的重试次数会被移除,因为切换后无法再修正。卡片按字段写入策略,因此未修改的继承字段保持继承。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。
+键入的 API 密钥按其自身字段判定:去除首尾空白后必须非空,且每个字符都必须是可打印 ASCII(`[\x21-\x7E]`),这正是 HTTP 头值能够携带的字符集——与 `@deepseek-ai/dsh-llm` 中的 `normalizeApiKey` 互为镜像,此处复刻是因为源平面拆分禁止导入它。与粘贴的 `NAME=value` 环境行一致或包裹在匹配引号内的值,会作为同样的格式失败被拒绝。空 id、重复 id、空显式名称以及不可读、非正数或小数的容量都会在任何写入之前失败。重试字段来自命名空间发布的 `retryPolicy` schema:每个 union 分支是一种模式,数字字段成为输入框,其整数步长、最小值和最大值按 schema 检查,字符串列表成为错误码勾选列表,嵌套对象成为折叠区,其他类型的字段不显示。标签、提示、错误消息和错误码名称是本页面拥有的文案;没有标签的字段显示其 schema 键名。`@deepseek-ai/dsh-llm` 中 `resolveRetryPolicy` 在字段上限之外还强制执行两条规则:初始延迟不大于最大延迟、错误码不重复,出于同样原因在此复刻,因为 `llm-deepseek` 对它拒绝的实时策略只记录警告。卡片按字段写入策略,因此未修改的继承字段保持继承。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。
### 并发与凭据
diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
index 336dc50307..89f3c6e4cf 100644
--- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
@@ -193,7 +193,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// A stored retry policy without a mode edits under the mode it inherits;
// the draft names it so a saved policy always carries one.
if (!isRecord(policy) || policy['mode'] !== undefined) return loaded
- const mode = schema.getPath(namespace.base, [...settingsPath, 'retryPolicy', 'mode']) ?? 'normal'
+ const path = [...settingsPath, 'retryPolicy']
+ const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), path)
+ /* v8 ignore next -- a stored retryPolicy implies the schema declares one */
+ if (node === undefined) return loaded
+ const { mode } = retryPolicyDefaults(schema, node, schema.getPath(namespace.base, path))
return { ...loaded, retryPolicy: { ...policy, mode } }
})
const [keyDraft, setKeyDraft] = useState(props.initialCredential ?? '')
diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
index e35e04373b..6097f9c7b2 100644
--- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
@@ -1,9 +1,11 @@
/**
- * The provider retry-policy fields of an editor card. Both adapter families
- * embed the same `retryPolicy` schema in a provider profile, so the card reads
- * its modes, defaults, and bounds from that schema instead of copying them.
- * An absent `retryPolicy` keeps the inherited policy; choosing a mode writes a
- * user-layer policy whose empty fields keep the inherited values.
+ * The provider retry-policy fields of an editor card, built from the
+ * `retryPolicy` schema the owning namespace publishes: its union branches are
+ * the modes, each branch's fields are the inputs, and the field bounds and
+ * defaults come from the schema. Number fields render as inputs, string lists
+ * as a code picker, and nested objects as a fold. An absent `retryPolicy`
+ * keeps the inherited policy; choosing a mode writes a user-layer policy whose
+ * empty fields keep the inherited values.
*/
import { useState } from 'react'
@@ -16,25 +18,25 @@ import styles from './ModelsSection.module.css'
/** One schema node of the provider's settings form. */
type SchemaNode = NonNullable>
-const CODES = ['retryableCodes'] as const
-const MAX_RETRIES = ['maxRetries'] as const
-const INITIAL_DELAY = ['backoff', 'initialDelayMs'] as const
-const MAX_DELAY = ['backoff', 'maxDelayMs'] as const
-const JITTER = ['backoff', 'jitterRatio'] as const
+/** Labels of known modes and fields by schema key; any other key shows verbatim. */
+const LABELS: ReadonlyMap = new Map([
+ ['normal', 'retryModeNormal'],
+ ['always', 'retryModeAlways'],
+ ['maxRetries', 'retryMaxRetries'],
+ ['retryableCodes', 'retryCodes'],
+ ['backoff', 'retryBackoff'],
+ ['initialDelayMs', 'retryInitialDelay'],
+ ['maxDelayMs', 'retryMaxDelay'],
+ ['jitterRatio', 'retryJitter'],
+])
-type FieldPath = typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER
-
-/** Backoff fields of both modes, as `[policy path, label]`. */
-const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [
- [INITIAL_DELAY, 'retryInitialDelay'],
- [MAX_DELAY, 'retryMaxDelay'],
- [JITTER, 'retryJitter'],
-]
+/** Hints shown under the mode select, by mode. */
+const MODE_HINTS: ReadonlyMap = new Map([['always', 'retryAlwaysHint']])
/**
- * Stable `LlmFailure` codes offered as retryable choices, with their labels: the
+ * Stable `LlmFailure` codes offered in a code picker, with their labels: the
* transient defaults of `dsh-llm`, then the other request failures a retry can
- * plausibly recover. A stored code outside this list stays listed after them.
+ * plausibly recover. Inherited and stored codes outside this list stay listed after them.
*/
const CODE_CHOICES: ReadonlyMap = new Map([
['EMPTY_RESPONSE', 'retryCodeEmptyResponse'],
@@ -50,42 +52,71 @@ const CODE_CHOICES: ReadonlyMap = new Map([
/** Plain decimal text; prefixed forms such as `0x10` stay invalid text. */
const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu
-/** The inherited policy beneath the user layer, plus the schema bounds of its fields. */
+/** The pair `resolveRetryPolicy` orders beyond any single field bound: initial delay at most the maximum. */
+const DELAY_ORDER = [['backoff', 'initialDelayMs'], ['backoff', 'maxDelayMs']] as const
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Read a nested field of a plain policy object. */
+function fieldAt(value: Record, path: readonly string[]): unknown {
+ return path.reduce((node, key) => isRecord(node) ? node[key] : undefined, value)
+}
+
+/** The mode a union branch selects, read from its `mode` constant. */
+function modeOf(branch: SchemaNode): string | undefined {
+ const mode: unknown = branch.dict?.['mode']?.value
+ return typeof mode === 'string' ? mode : undefined
+}
+
+/** The editable fields of one object node, in schema order, without the `mode` discriminant. */
+function fieldsOf(node: SchemaNode): [string, SchemaNode][] {
+ /* v8 ignore next -- object schema nodes always carry `dict` */
+ return Object.entries(node.dict ?? {}).filter(([key]) => key !== 'mode')
+}
+
+/** The inherited policy beneath the user layer, and the schema branch of each mode. */
export interface RetryPolicyDefaults {
+ /** Modes in schema order. */
+ modes: readonly string[]
/** Mode in effect when the user layer names none. */
- mode: 'normal' | 'always'
- /** Read the inherited value of one policy field: the composition layer, then the schema default. */
- value: (path: readonly string[]) => unknown
- /** Read the schema maximum of one numeric policy field. */
- max: (path: readonly string[]) => number | undefined
+ mode: string
+ /** Schema branch of a stored `mode` value, or undefined when no branch declares it. */
+ branch: (mode: unknown) => SchemaNode | undefined
+ /** Inherited value of one field of a branch: the composition layer, then the branch default. */
+ value: (branch: SchemaNode, path: readonly string[]) => unknown
}
/**
- * Resolve the inherited policy beneath the user layer.
+ * Resolve the modes and inherited policy beneath the user layer.
* @param schema - settings schema operations.
* @param node - the `retryPolicy` schema union.
* @param inherited - the policy the composition layer sets, if any.
- * @returns the inherited mode, field values, and field bounds.
+ * @returns the modes and the inherited mode and field values.
*/
export function retryPolicyDefaults(
schema: SettingsSchemaOperations,
node: SchemaNode,
inherited: unknown,
): RetryPolicyDefaults {
- const normal = node.list?.find(branch => branch.dict?.['mode']?.value === 'normal')
- const field = (path: readonly string[]) => normal === undefined ? undefined : schema.nodeAtPath(normal, path)
+ // A schema that is not a union of mode branches has no `list` and offers no mode.
+ const branches = new Map((node.list ?? []).flatMap((branch) => {
+ const mode = modeOf(branch)
+ return mode === undefined ? [] : [[mode, branch] as const]
+ }))
+ const modes = [...branches.keys()]
+ const stored = schema.getPath(inherited, ['mode'])
return {
- mode: schema.getPath(inherited, ['mode']) === 'always' ? 'always' : 'normal',
- value: (path) => {
+ modes,
+ mode: typeof stored === 'string' && branches.has(stored) ? stored : modes[0] ?? '',
+ branch: mode => typeof mode === 'string' ? branches.get(mode) : undefined,
+ value: (branch, path) => {
const value = schema.getPath(inherited, path)
if (value !== undefined) return value
- const fallback: unknown = field(path)?.meta.default
+ const fallback: unknown = schema.nodeAtPath(branch, path)?.meta.default
return fallback
},
- max: (path) => {
- const max: unknown = field(path)?.meta.max
- return typeof max === 'number' ? max : undefined
- },
}
}
@@ -95,11 +126,25 @@ export interface RetryPolicyFailure {
path?: readonly string[]
}
+/** The first bound of a number field's schema that `value` breaks, as copy. */
+function numberFailure(node: SchemaNode, value: unknown, t: (key: ModelsKey) => string): string | undefined {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return t('retryValueNumber')
+ const { min, max, step } = node.meta
+ if (step === 1 && !Number.isInteger(value)) return t('retryValueInteger')
+ // `Number.MIN_VALUE` is the schema idiom for a positive bound.
+ if (min === Number.MIN_VALUE && value <= 0) return t('retryValuePositive')
+ if (typeof min === 'number' && value < min) return t('retryValueMin').replace('{min}', () => String(min))
+ if (typeof max === 'number' && value > max) return t('retryValueMax').replace('{max}', () => String(max))
+ return undefined
+}
+
/**
- * The first refused field of a drafted policy, checked as `resolveRetryPolicy`
- * checks the merged policy: fields the draft leaves empty inherit.
+ * The first refused field of a drafted policy, checked against the selected
+ * branch's field bounds with empty fields inherited, then against the
+ * cross-field rules `resolveRetryPolicy` enforces beyond the schema, then
+ * against the whole schema.
* @param schema - settings schema operations.
- * @param node - the `retryPolicy` schema union, which bounds every field.
+ * @param node - the `retryPolicy` schema union.
* @param policy - the drafted user-layer policy; undefined inherits.
* @param defaults - the inherited policy beneath the draft.
* @param t - section copy.
@@ -113,43 +158,50 @@ export function retryPolicyFailure(
t: (key: ModelsKey) => string,
): RetryPolicyFailure | undefined {
if (policy === undefined) return undefined
- if (typeof policy !== 'object' || policy === null || Array.isArray(policy)) {
+ const whole = (): RetryPolicyFailure | undefined => {
const message = schema.validate(node, policy)
return message === undefined ? undefined : { message }
}
- const fail = (key: ModelsKey, path: readonly string[]): RetryPolicyFailure => ({ message: t(key), path })
- const number = (path: readonly string[]): number => {
- const value = schema.getPath(policy, path) ?? defaults.value(path)
- return typeof value === 'number' ? value : NaN
- }
- if (schema.getPath(policy, ['mode']) === 'normal') {
- const retries = number(MAX_RETRIES)
- if (!Number.isSafeInteger(retries) || retries < 0) return fail('retryMaxRetriesInvalid', MAX_RETRIES)
- const codes = schema.getPath(policy, CODES)
- if (Array.isArray(codes)) {
- if (codes.length === 0 || codes.some(code => typeof code !== 'string' || code.length === 0)) {
- return fail('retryCodesInvalid', CODES)
+ if (!isRecord(policy)) return whole()
+ const branch = defaults.branch(policy['mode'])
+ if (branch === undefined) return whole()
+ const read = (path: readonly string[]): unknown => schema.getPath(policy, path) ?? defaults.value(branch, path)
+ const check = (owner: SchemaNode, base: readonly string[]): RetryPolicyFailure | undefined => {
+ for (const [key, field] of fieldsOf(owner)) {
+ const path = [...base, key]
+ if (field.type === 'object') {
+ const nested = check(field, path)
+ if (nested !== undefined) return nested
+ } else if (field.type === 'number') {
+ const value = read(path)
+ // A field with neither a value nor a default is left to the schema check below.
+ const message = value === undefined ? undefined : numberFailure(field, value, t)
+ if (message !== undefined) return { message, path }
+ } else if (field.type === 'array') {
+ const stored = schema.getPath(policy, path)
+ if (stored === undefined) continue
+ if (schema.validate(field, stored) !== undefined) return { message: t('retryCodesInvalid'), path }
+ if (Array.isArray(stored) && new Set(stored).size !== stored.length) return { message: t('retryCodesDuplicate'), path }
}
- if (new Set(codes).size !== codes.length) return fail('retryCodesDuplicate', CODES)
}
+ return undefined
}
- for (const path of [INITIAL_DELAY, MAX_DELAY]) {
- const delay = number(path)
- const max = defaults.max(path) ?? Infinity
- if (!(delay > 0 && delay <= max)) return { message: t('retryDelayInvalid').replace('{max}', () => String(max)), path }
+ const failure = check(branch, [])
+ if (failure !== undefined) return failure
+ const [lowerPath, upperPath] = DELAY_ORDER
+ const lower = read(lowerPath)
+ const upper = read(upperPath)
+ if (typeof lower === 'number' && typeof upper === 'number' && lower > upper) {
+ return { message: t('retryDelayOrder'), path: lowerPath }
}
- if (number(INITIAL_DELAY) > number(MAX_DELAY)) return fail('retryDelayOrder', INITIAL_DELAY)
- const jitter = number(JITTER)
- if (!(jitter >= 0 && jitter <= 1)) return fail('retryJitterInvalid', JITTER)
- const message = schema.validate(node, policy)
- return message === undefined ? undefined : { message }
+ return whole()
}
/** Props of {@link RetryPolicyEditor}. */
export interface RetryPolicyEditorProps {
/** Drafted user-layer policy; undefined inherits. */
policy: unknown
- /** Inherited policy beneath the draft. */
+ /** Inherited policy and schema branches beneath the draft. */
defaults: RetryPolicyDefaults
/** Failure from {@link retryPolicyFailure}. */
failure: RetryPolicyFailure | undefined
@@ -169,22 +221,16 @@ function textOf(value: unknown): string {
return typeof value === 'number' || typeof value === 'string' ? String(value) : ''
}
-/** Set or remove one field of a plain policy object, dropping an emptied `backoff`. */
-function withField(policy: Record, path: FieldPath | typeof CODES, value: unknown): Record {
- const assign = (target: Record, key: string, next: unknown): void => {
- if (next === undefined) Reflect.deleteProperty(target, key)
- else target[key] = next
- }
+/** Set or remove one nested field of a plain policy object, dropping emptied parents. */
+function withField(policy: Record, path: readonly string[], value: unknown): Record {
+ const [key, ...rest] = path
+ /* v8 ignore next -- paths come from schema fields and are never empty */
+ if (key === undefined) return policy
const result = { ...policy }
- if (path.length === 1) {
- assign(result, path[0], value)
- return result
- }
- const [parent, key] = path
- const prior = result[parent]
- const child = typeof prior === 'object' && prior !== null ? { ...prior as Record } : {}
- assign(child, key, value)
- assign(result, parent, Object.keys(child).length === 0 ? undefined : child)
+ const prior = result[key]
+ const next = rest.length === 0 ? value : withField(isRecord(prior) ? prior : {}, rest, value)
+ if (next === undefined || (isRecord(next) && Object.keys(next).length === 0)) Reflect.deleteProperty(result, key)
+ else result[key] = next
return result
}
@@ -195,64 +241,66 @@ function withField(policy: Record, path: FieldPath | typeof COD
*/
export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
const { t, disabled, defaults, failure } = props
- const policy = typeof props.policy === 'object' && props.policy !== null
- ? props.policy as Record
- : undefined
- const current = policy ?? {}
- const mode = current['mode']
+ const current = isRecord(props.policy) ? props.policy : {}
+ const branch = defaults.branch(current['mode'])
+ const mode = branch === undefined ? undefined : String(current['mode'])
// Raw text keeps partial input such as `0.` visible while it is typed.
const [texts, setTexts] = useState>({})
- // Backoff starts folded unless the draft already customizes it.
- const [backoffOpen] = useState(() => current['backoff'] !== undefined)
- const textAt = (path: FieldPath): string => texts[path.join('.')] ?? textOf(path.reduce(
- (value, key) => typeof value === 'object' && value !== null ? (value as Record)[key] : undefined, current,
- ))
- const edit = (path: FieldPath, text: string): void => {
- const trimmed = text.trim()
- setTexts(previous => ({ ...previous, [path.join('.')]: trimmed.length === 0 ? '' : text }))
- const value = DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
- props.onChange(withField(current, path, value === '' ? undefined : value))
+ // A nested fold starts open only when the draft already customizes it.
+ const [openFolds] = useState(() => new Set(Object.keys(current).filter(key => isRecord(current[key]))))
+ const label = (key: string): string => {
+ const labelKey = LABELS.get(key)
+ return labelKey === undefined ? key : t(labelKey)
}
- const field = (path: FieldPath, label: ModelsKey, className = styles['modelField']): ReactNode => {
- const invalid = failure?.path?.join('.') === path.join('.')
+ const invalidAt = (path: readonly string[]): boolean => failure?.path?.join('.') === path.join('.')
+ const numberField = (root: SchemaNode, path: readonly string[], node: SchemaNode, className: string | undefined): ReactNode => {
+ const id = path.join('.')
+ const invalid = invalidAt(path)
+ const storedValue = fieldAt(current, path)
return (
-
- {t(label)}
+
+ {label(path[path.length - 1] as string)}
{ edit(path, event.target.value) }}
+ onChange={(event) => {
+ const trimmed = event.target.value.trim()
+ setTexts(previous => ({ ...previous, [id]: trimmed.length === 0 ? '' : event.target.value }))
+ const value = DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
+ props.onChange(withField(current, path, value === '' ? undefined : value))
+ }}
/>
)
}
- const codesField = (): ReactNode => {
+ const codesField = (root: SchemaNode, path: readonly string[]): ReactNode => {
const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(code => typeof code === 'string') : []
- const inherited = strings(defaults.value(CODES))
- const stored = current['retryableCodes']
- const selected = Array.isArray(stored) ? strings(stored) : inherited
+ const inherited = strings(defaults.value(root, path))
+ const storedCodes = fieldAt(current, path)
+ const selected = Array.isArray(storedCodes) ? strings(storedCodes) : inherited
const choices = [...new Set([...CODE_CHOICES.keys(), ...inherited, ...selected])]
- const invalid = failure?.path?.join('.') === CODES.join('.')
+ const invalid = invalidAt(path)
return (
- {t('retryCodes')}
+ {label(path[path.length - 1] as string)}
{choices.map((code) => {
const labelKey = CODE_CHOICES.get(code)
return (
value === code ? checked : selected.includes(value))
// Selecting exactly the inherited codes keeps the field inherited.
const inherits = next.length === inherited.length && next.every(value => inherited.includes(value))
- props.onChange(withField(current, CODES, inherits ? undefined : next))
+ props.onChange(withField(current, path, inherits ? undefined : next))
}}
/>
)
@@ -269,53 +317,52 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
)
}
+ // Top-level number fields share the width of one nested column so they line up with it.
+ const fields = (root: SchemaNode, owner: SchemaNode, base: readonly string[]): ReactNode[] => fieldsOf(owner).map(([key, node]) => {
+ const path = [...base, key]
+ if (node.type === 'number') {
+ return numberField(root, path, node, base.length === 0 ? `${styles['modelField']} ${styles['retryCount']}` : styles['modelField'])
+ }
+ if (node.type === 'array') return codesField(root, path)
+ if (node.type === 'object') {
+ return (
+
+ {label(key)}
+ {fields(root, node, path)}
+
+ )
+ }
+ return null
+ })
const labelId = `${props.failureId}-label`
- const modeLabel = (value: 'normal' | 'always'): string => t(value === 'normal' ? 'retryModeNormal' : 'retryModeAlways')
+ const hint = mode === undefined ? undefined : MODE_HINTS.get(mode)
return (
{t('retryPolicy')}
{
- const next = event.target.value
+ const next = defaults.branch(event.target.value)
setTexts({})
- if (next === '') {
+ if (next === undefined) {
props.onChange(undefined)
return
}
- // Fields of the other mode stay, as the adapter accepts them; a hidden
- // retry count that is not a number could no longer be corrected.
- const kept = next === 'always' && typeof current['maxRetries'] !== 'number'
- ? withField(current, MAX_RETRIES, undefined)
- : current
- props.onChange({ ...kept, mode: next })
+ // A switched policy keeps only the fields its new mode declares.
+ const declared = new Set(fieldsOf(next).map(([key]) => key))
+ const kept = Object.fromEntries(Object.entries(current).filter(([key]) => declared.has(key)))
+ props.onChange({ ...kept, mode: event.target.value })
}}
>
- {t('retryModeDefault').replace('{mode}', () => modeLabel(defaults.mode))}
- {modeLabel('normal')}
- {modeLabel('always')}
+ {t('retryModeDefault').replace('{mode}', () => label(defaults.mode))}
+ {defaults.modes.map(value => {label(value)} )}
- {mode === 'always' ?
{t('retryAlwaysHint')} : null}
- {mode === 'normal' || mode === 'always'
- ? (
-
- {mode === 'normal'
- ? <>
- {field(MAX_RETRIES, 'retryMaxRetries', `${styles['modelField']} ${styles['retryCount']}`)}
- {codesField()}
- >
- : null}
-
- {t('retryBackoff')}
- {BACKOFF_FIELDS.map(([path, label]) => field(path, label))}
-
-
- )
- : null}
+ {hint === undefined ? null :
{t(hint)} }
+ {branch === undefined ? null :
{fields(branch, branch, [])}
}
{failure === undefined || !props.showFailure
? null
:
{failure.message}
}
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index f8dc7dc17f..bec003197f 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -74,12 +74,14 @@ export const en = {
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
retryBackoff: 'Backoff',
- retryMaxRetriesInvalid: 'Max retries must be a whole number, 0 or more.',
+ retryValueNumber: 'Enter a number.',
+ retryValueInteger: 'Enter a whole number.',
+ retryValuePositive: 'Must be greater than 0.',
+ retryValueMin: 'Must be at least {min}.',
+ retryValueMax: 'Must be at most {max}.',
retryCodesInvalid: 'Error codes must be non-empty text.',
retryCodesDuplicate: 'Each error code may appear once.',
- retryDelayInvalid: 'Delays must be positive and at most {max} ms.',
retryDelayOrder: 'Initial delay cannot exceed max delay.',
- retryJitterInvalid: 'Jitter ratio must be between 0 and 1.',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
@@ -242,12 +244,14 @@ export const zh: { [Key in keyof typeof en]: string } = {
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
retryBackoff: '退避',
- retryMaxRetriesInvalid: '最大重试次数必须是不小于 0 的整数。',
+ retryValueNumber: '请输入数字。',
+ retryValueInteger: '请输入整数。',
+ retryValuePositive: '必须大于 0。',
+ retryValueMin: '不能小于 {min}。',
+ retryValueMax: '不能大于 {max}。',
retryCodesInvalid: '错误码必须是非空文本。',
retryCodesDuplicate: '每个错误码只能出现一次。',
- retryDelayInvalid: '延迟必须是正数,且不超过 {max} ms。',
retryDelayOrder: '初始延迟不能大于最大延迟。',
- retryJitterInvalid: '抖动比例必须在 0 到 1 之间。',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
@@ -407,12 +411,14 @@ export const ja = {
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
'retryBackoff': 'バックオフ',
- 'retryMaxRetriesInvalid': '最大再試行回数は0以上の整数で指定してください。',
+ 'retryValueNumber': '数値を入力してください。',
+ 'retryValueInteger': '整数を入力してください。',
+ 'retryValuePositive': '0 より大きい値にしてください。',
+ 'retryValueMin': '{min} 以上にしてください。',
+ 'retryValueMax': '{max} 以下にしてください。',
'retryCodesInvalid': 'エラーコードは空でないテキストにしてください。',
'retryCodesDuplicate': '各エラーコードは1回のみ指定できます。',
- 'retryDelayInvalid': '遅延は {max} ms 以下の正の値で指定してください。',
'retryDelayOrder': '初期遅延は最大遅延以下にしてください。',
- 'retryJitterInvalid': 'ジッター比率は0から1の範囲で指定してください。',
'models': 'モデル',
'modelsInherited': 'アダプターのデフォルトを使用',
'modelsCustomized': 'カスタマイズされたモデルカタログ',
@@ -572,12 +578,14 @@ export const es = {
retryMaxDelay: 'Retraso máximo (ms)',
retryJitter: 'Proporción de jitter',
retryBackoff: 'Retroceso',
- retryMaxRetriesInvalid: 'Los reintentos máximos deben ser un número entero igual o mayor que 0.',
+ retryValueNumber: 'Ingrese un número.',
+ retryValueInteger: 'Ingrese un número entero.',
+ retryValuePositive: 'Debe ser mayor que 0.',
+ retryValueMin: 'Debe ser al menos {min}.',
+ retryValueMax: 'Debe ser como máximo {max}.',
retryCodesInvalid: 'Los códigos de error deben ser texto no vacío.',
retryCodesDuplicate: 'Cada código de error solo puede aparecer una vez.',
- retryDelayInvalid: 'Los retrasos deben ser positivos y como máximo de {max} ms.',
retryDelayOrder: 'El retraso inicial no puede superar el retraso máximo.',
- retryJitterInvalid: 'La proporción de variación aleatoria debe estar entre 0 y 1.',
models: 'Modelos',
modelsInherited: 'Usando los valores predeterminados del adaptador',
modelsCustomized: 'Catálogo de modelos personalizado',
@@ -737,12 +745,14 @@ export const fr = {
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
retryBackoff: 'Temporisation',
- retryMaxRetriesInvalid: 'Le nombre maximum de tentatives doit être un entier supérieur ou égal à 0.',
+ retryValueNumber: 'Saisissez un nombre.',
+ retryValueInteger: 'Saisissez un nombre entier.',
+ retryValuePositive: 'Doit être supérieur à 0.',
+ retryValueMin: 'Doit être supérieur ou égal à {min}.',
+ retryValueMax: 'Doit être inférieur ou égal à {max}.',
retryCodesInvalid: "Les codes d'erreur doivent être du texte non vide.",
retryCodesDuplicate: "Chaque code d'erreur ne peut apparaître qu'une fois.",
- retryDelayInvalid: 'Les délais doivent être positifs et ne pas dépasser {max} ms.',
retryDelayOrder: 'Le délai initial ne peut pas dépasser le délai maximum.',
- retryJitterInvalid: 'Le ratio de gigue doit être compris entre 0 et 1.',
models: 'Modèles',
modelsInherited: "Utilisation des valeurs par défaut de l'adaptateur",
modelsCustomized: 'Catalogue de modèles personnalisés',
@@ -902,12 +912,14 @@ export const de = {
retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
retryBackoff: 'Backoff',
- retryMaxRetriesInvalid: 'Die maximale Anzahl an Wiederholungen muss eine ganze Zahl ab 0 sein.',
+ retryValueNumber: 'Geben Sie eine Zahl ein.',
+ retryValueInteger: 'Geben Sie eine ganze Zahl ein.',
+ retryValuePositive: 'Muss größer als 0 sein.',
+ retryValueMin: 'Muss mindestens {min} sein.',
+ retryValueMax: 'Darf höchstens {max} sein.',
retryCodesInvalid: 'Fehlercodes müssen nicht leerer Text sein.',
retryCodesDuplicate: 'Jeder Fehlercode darf nur einmal vorkommen.',
- retryDelayInvalid: 'Verzögerungen müssen positiv sein und dürfen höchstens {max} ms betragen.',
retryDelayOrder: 'Die anfängliche Verzögerung darf die maximale Verzögerung nicht überschreiten.',
- retryJitterInvalid: 'Der Jitter-Anteil muss zwischen 0 und 1 liegen.',
models: 'Modelle',
modelsInherited: 'Verwendung der Adapterstandards',
modelsCustomized: 'Maßgeschneiderter Modellkatalog',
@@ -1067,12 +1079,14 @@ export const ru = {
retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
retryBackoff: 'Отсрочка',
- retryMaxRetriesInvalid: 'Максимальное количество повторных попыток должно быть целым числом не меньше 0.',
+ retryValueNumber: 'Введите число.',
+ retryValueInteger: 'Введите целое число.',
+ retryValuePositive: 'Должно быть больше 0.',
+ retryValueMin: 'Должно быть не меньше {min}.',
+ retryValueMax: 'Должно быть не больше {max}.',
retryCodesInvalid: 'Коды ошибок должны быть непустым текстом.',
retryCodesDuplicate: 'Каждый код ошибки может встречаться только один раз.',
- retryDelayInvalid: 'Задержки должны быть положительными и не превышать {max} ms.',
retryDelayOrder: 'Начальная задержка не может превышать максимальную.',
- retryJitterInvalid: 'Коэффициент джиттера должен быть от 0 до 1.',
models: 'Модели',
modelsInherited: 'Использование настроек адаптера по умолчанию',
modelsCustomized: 'Индивидуальный каталог моделей',
@@ -1232,12 +1246,14 @@ export const pt = {
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
retryBackoff: 'Backoff',
- retryMaxRetriesInvalid: 'O máximo de novas tentativas deve ser um número inteiro maior ou igual a 0.',
+ retryValueNumber: 'Insira um número.',
+ retryValueInteger: 'Insira um número inteiro.',
+ retryValuePositive: 'Deve ser maior que 0.',
+ retryValueMin: 'Deve ser no mínimo {min}.',
+ retryValueMax: 'Deve ser no máximo {max}.',
retryCodesInvalid: 'Os códigos de erro devem ser texto não vazio.',
retryCodesDuplicate: 'Cada código de erro só pode aparecer uma vez.',
- retryDelayInvalid: 'Os atrasos devem ser positivos e de no máximo {max} ms.',
retryDelayOrder: 'O atraso inicial não pode exceder o atraso máximo.',
- retryJitterInvalid: 'A proporção de jitter deve estar entre 0 e 1.',
models: 'Modelos',
modelsInherited: 'Usando os padrões do adaptador',
modelsCustomized: 'Catálogo de modelos personalizados',
diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
index 7d8ef15daa..4fbf4a5138 100644
--- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
@@ -14,15 +14,15 @@ afterEach(cleanup)
// The `retryPolicy` schema both adapters embed (`RetryPolicySchema` in dsh-llm).
const backoff = Schema.object({
- initialDelayMs: Schema.number().max(2_147_483_647).default(500),
- maxDelayMs: Schema.number().max(2_147_483_647).default(10_000),
+ initialDelayMs: Schema.number().min(Number.MIN_VALUE).max(2_147_483_647).default(500),
+ maxDelayMs: Schema.number().min(Number.MIN_VALUE).max(2_147_483_647).default(10_000),
jitterRatio: Schema.number().min(0).max(1).default(0.1),
})
const RetryPolicy = Schema.union([
Schema.object({
mode: Schema.const('normal').required(),
maxRetries: Schema.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(5),
- retryableCodes: Schema.array(Schema.string()).default(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']),
+ retryableCodes: Schema.array(Schema.string().min(1)).min(1).default(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']),
backoff,
}),
Schema.object({ mode: Schema.const('always').required(), backoff }),
@@ -58,6 +58,7 @@ function piAiNamespace(): SettingsNamespaceView {
function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) {
const writeSettings = vi.fn
(() => Promise.resolve({ kind: 'written', view: namespace }))
const operations: ModelsOperations = {
+ readCredential: vi.fn(() => Promise.resolve({ kind: 'refused', message: '' })),
storeCredential: vi.fn(() => Promise.resolve(undefined)),
removeCredential: vi.fn(() => Promise.resolve(undefined)),
writeSettings,
@@ -113,16 +114,15 @@ describe('retry policy fields', () => {
], 3)
})
- const delayInvalid = en.retryDelayInvalid.replace('{max}', '2147483647')
it.each([
- [en.retryMaxRetries, '-1', en.retryMaxRetriesInvalid],
- [en.retryMaxRetries, '1.5', en.retryMaxRetriesInvalid],
- [en.retryMaxRetries, 'many', en.retryMaxRetriesInvalid],
- [en.retryInitialDelay, '0', delayInvalid],
- [en.retryInitialDelay, '0x10', delayInvalid],
- [en.retryMaxDelay, '3000000000', delayInvalid],
+ [en.retryMaxRetries, '-1', en.retryValueMin.replace('{min}', '0')],
+ [en.retryMaxRetries, '1.5', en.retryValueInteger],
+ [en.retryMaxRetries, 'many', en.retryValueNumber],
+ [en.retryInitialDelay, '0', en.retryValuePositive],
+ [en.retryInitialDelay, '0x10', en.retryValueNumber],
+ [en.retryMaxDelay, '3000000000', en.retryValueMax.replace('{max}', '2147483647')],
[en.retryInitialDelay, '20000', en.retryDelayOrder],
- [en.retryJitter, '2', en.retryJitterInvalid],
+ [en.retryJitter, '2', en.retryValueMax.replace('{max}', '1')],
])('refuses %s = %s', (label, value, message) => {
setup(deepSeekNamespace())
fireEvent.change(mode(), { target: { value: 'normal' } })
@@ -280,16 +280,76 @@ describe('retry policy fields', () => {
])
})
- it('leaves placeholders empty when the schema declares no normal mode', () => {
+ it('builds modes and fields from the schema the namespace publishes', async () => {
const namespace = deepSeekNamespace()
- const always = Schema.union([Schema.object({ mode: Schema.const('always').required() })])
- setup({ ...namespace, schema: json(Schema.object({ retryPolicy: always })) })
- fireEvent.change(mode(), { target: { value: 'always' } })
+ const policy = Schema.union([
+ Schema.object({
+ mode: Schema.const('patient').required(),
+ attempts: Schema.number().step(1).min(1),
+ codes: Schema.array(Schema.string()),
+ note: Schema.string(),
+ backoff: Schema.object({ maxDelayMs: Schema.number().max(100) }),
+ }),
+ Schema.object({ mode: Schema.const('always').required() }),
+ ])
+ const { writeSettings } = setup({ ...namespace, schema: json(Schema.object({ retryPolicy: policy })) })
+ expect(mode().selectedOptions[0]?.textContent).toBe('Default (patient)')
+ expect(screen.getByRole('option', { name: en.retryModeAlways })).toBeTruthy()
+ fireEvent.change(mode(), { target: { value: 'patient' } })
+ expect(input('attempts').inputMode).toBe('numeric')
+ expect(input(en.retryMaxDelay).inputMode).toBe('decimal')
expect(input(en.retryMaxDelay).placeholder).toBe('')
+ expect(code_('SERVER').checked).toBe(false)
+ expect(screen.queryByText('note')).toBeNull()
+ expect(screen.queryByLabelText(en.retryInitialDelay)).toBeNull()
+ fireEvent.change(input('attempts'), { target: { value: '0' } })
+ expect(screen.getByText(en.retryValueMin.replace('{min}', '1'))).toBeTruthy()
+ fireEvent.change(input('attempts'), { target: { value: '2' } })
+ expect(document.getElementById('route-retry-failure')).toBeNull()
+ fireEvent.click(apply())
+ await waitFor(() => { expect(writeSettings).toHaveBeenCalled() })
+ expect(writeSettings.mock.calls[0]?.[1]).toEqual([
+ { op: 'set', path: ['retryPolicy', 'mode'], value: 'patient' },
+ { op: 'set', path: ['retryPolicy', 'attempts'], value: 2 },
+ ])
+ })
+
+ it('offers only Default when the retry schema declares no mode branches', () => {
+ const namespace = deepSeekNamespace()
+ const policy = Schema.union([Schema.object({ backoff: Schema.object({}) }), Schema.const(false)])
+ setup({ ...namespace, schema: json(Schema.object({ retryPolicy: policy })) })
+ expect(mode().options).toHaveLength(1)
+ cleanup()
+ setup({ ...namespace, schema: json(Schema.object({ retryPolicy: Schema.object({ mode: Schema.string() }) })) })
+ expect(mode().options).toHaveLength(1)
+ })
+
+ it('lists no inherited codes when the inherited value is not a list', () => {
+ setup(deepSeekNamespace({ base: { retryPolicy: { mode: 'normal', retryableCodes: 'SERVER' } } }))
fireEvent.change(mode(), { target: { value: 'normal' } })
expect(code_('SERVER').checked).toBe(false)
})
+ it('refuses a stored policy whose mode is not text', () => {
+ setup(deepSeekNamespace({ user: { retryPolicy: { mode: 5 } } }))
+ expect(mode().value).toBe('')
+ expect(apply().disabled).toBe(true)
+ })
+
+ it('keeps only the fields the new mode declares when switching modes', async () => {
+ const { writeSettings } = setup(deepSeekNamespace({
+ user: { retryPolicy: { mode: 'normal', maxRetries: 2, backoff: { maxDelayMs: 2_000 } } },
+ }))
+ expect(input(en.retryMaxDelay).value).toBe('2000')
+ fireEvent.change(mode(), { target: { value: 'always' } })
+ fireEvent.click(apply())
+ await waitFor(() => { expect(writeSettings).toHaveBeenCalled() })
+ expect(writeSettings.mock.calls[0]?.[1]).toEqual([
+ { op: 'set', path: ['retryPolicy', 'mode'], value: 'always' },
+ { op: 'unset', path: ['retryPolicy', 'maxRetries'] },
+ ])
+ })
+
it('renders no retry fields when the namespace schema has no retry policy', () => {
const namespace = deepSeekNamespace()
setup({ ...namespace, schema: json(Schema.object({ baseURL: Schema.string() })) })
diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts
index f6e6175cb9..f999a04b63 100644
--- a/packages/llm/llm/src/retry-policy.ts
+++ b/packages/llm/llm/src/retry-policy.ts
@@ -78,16 +78,18 @@ export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {
/** Immutable provider policy captured when its adapter route is registered. */
export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy
+// Field bounds live in the schema so settings forms can read them; resolveRetryPolicy
+// additionally enforces the cross-field rules no field bound expresses.
const backoffSchema: z = z.object({
- initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
- maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
+ initialDelayMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
+ maxDelayMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
})
const normalPolicySchema: z = z.object({
mode: z.const('normal').required(),
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
- retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
+ retryableCodes: z.array(z.string().min(1)).min(1).default([...DEFAULT_RETRYABLE_CODES]),
backoff: backoffSchema,
})
diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts
index cc7ebb8fa7..4059858724 100644
--- a/packages/llm/llm/tests/retry-policy.spec.ts
+++ b/packages/llm/llm/tests/retry-policy.spec.ts
@@ -96,4 +96,20 @@ describe('provider retry policy', () => {
resolveRetryPolicy(config as unknown as RetryPolicyConfig, 'provider.retryPolicy')
}).toThrow(message)
})
+
+ it.each<[string, RetryPolicyConfig]>([
+ ['a zero delay', { mode: 'always', backoff: { initialDelayMs: 0 } }],
+ ['an empty code list', { mode: 'normal', retryableCodes: [] }],
+ ['a blank code', { mode: 'normal', retryableCodes: [''] }],
+ ])('declares the resolver bound on %s in the schema', (_name, config) => {
+ expect(() => RetryPolicySchema(config)).toThrow()
+ })
+
+ it('accepts the smallest values inside the schema bounds and still defaults the codes', () => {
+ expect(RetryPolicySchema({ mode: 'normal', retryableCodes: ['X'], backoff: { initialDelayMs: Number.MIN_VALUE } }))
+ .toMatchObject({ retryableCodes: ['X'], backoff: { initialDelayMs: Number.MIN_VALUE } })
+ expect(RetryPolicySchema({ mode: 'normal' })).toMatchObject({
+ retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
+ })
+ })
})
From 035ef9b37d4450a97d5af34cb0d617b636a47348 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Tue, 3 Nov 2026 16:29:23 +0800
Subject: [PATCH 011/106] feat(llm): define built-in failure codes once and
offer retry choices through the schema
Refs #7005
---
.../llm-error-builtin-codes/guide.i18n.yaml | 16 ++++
.../llm-error-builtin-codes/guide.md | 18 +++++
.../llm-error-builtin-codes/guide.zh.md | 18 +++++
.../ui-settings-models/README.i18n.yaml | 4 +-
packages/client/ui-settings-models/README.md | 2 +-
.../client/ui-settings-models/README.zh.md | 2 +-
.../src/client/ModelsSection.module.css | 7 ++
.../src/client/RetryPolicyEditor.tsx | 74 +++++++++++++----
.../ui-settings-models/src/client/locales.ts | 16 ++++
.../tests/retry-policy.client.spec.tsx | 54 ++++++++++++-
.../compaction-basic/src/summarizer.ts | 2 +-
packages/core/agent-loop/src/agent.ts | 2 +-
.../agent-loop/tests/coverage-edges.spec.ts | 2 +-
packages/core/agent-loop/tests/loop.spec.ts | 2 +-
.../agent-loop/tests/request-error.spec.ts | 4 +-
.../tests/request-reconstruction.spec.ts | 2 +-
.../tests/system-prompt-admission.spec.ts | 2 +-
.../tests/account-tasks.spec.ts | 2 +-
packages/llm/llm-deepseek/src/adapter.ts | 2 +-
packages/llm/llm-deepseek/src/replay.ts | 4 +-
packages/llm/llm-deepseek/src/transport.ts | 3 +-
packages/llm/llm-pi-ai/src/stream.ts | 10 +--
packages/llm/llm-pi-ai/tests/adapter.spec.ts | 2 +-
packages/llm/llm/README.i18n.yaml | 4 +-
packages/llm/llm/README.md | 2 +-
packages/llm/llm/README.zh.md | 2 +-
packages/llm/llm/src/adapter-failure.ts | 5 +-
packages/llm/llm/src/error.ts | 81 +++++++++++++++++--
packages/llm/llm/src/index.ts | 19 ++++-
packages/llm/llm/src/retry-policy.ts | 10 ++-
packages/llm/llm/tests/retry-policy.spec.ts | 24 ++++++
packages/llm/llm/tests/service.spec.ts | 6 +-
packages/test-support/llm-replay/src/index.ts | 2 +-
33 files changed, 345 insertions(+), 60 deletions(-)
create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.i18n.yaml
create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md
create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.i18n.yaml
new file mode 100644
index 0000000000..b43a0db6e7
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md
+/:
+ en: a3bc51b72528488c
+ zh: 1feb265c92492487
+/new-llmerror-accepts-only-built-in-failure-codes:
+ en: 390a9a983fc10296
+ zh: 2ce8cbc8c8661acf
+/new-llmerror-accepts-only-built-in-failure-codes/change:
+ en: db9bc6fe227f0d41
+ zh: 9ef71a6e7a2214be
+/new-llmerror-accepts-only-built-in-failure-codes/migration:
+ en: 605b8836fd1872e9
+ zh: 7cd1e58bb9cdb49e
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md
new file mode 100644
index 0000000000..c1ab6a5494
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.md
@@ -0,0 +1,18 @@
+---
+kind: upgrade-guide
+description: "`new LlmError` accepts only built-in failure codes; other codes go through `LlmError.fromFailure`."
+---
+
+# `new LlmError` accepts only built-in failure codes
+
+English | [中文](guide.zh.md)
+
+## Change
+
+In `@deepseek-ai/dsh-llm`, the `code` parameter of the `LlmError` constructor was `string` and is now `BuiltinLlmFailureCode`: a key of `LLM_FAILURE_CODES` or `HTTP_`. Runtime behavior and `LlmFailure.code` are unchanged. TypeScript code that constructs `LlmError` with any other code, such as an out-of-tree provider adapter or a test double, no longer compiles.
+
+## Migration
+
+1. Replace each `new LlmError(message, code, options)` whose `code` is not built-in with `LlmError.fromFailure({ message, code, ...facts }, { cause })`, where `facts` holds any `status`, `providerRetryAfterMs`, `requestId`, or `offloadImages`.
+2. Re-raise a failure received from a stream with `LlmError.fromFailure(finish.failure)` instead of passing `finish.failure.code` to the constructor.
+3. Confirm with `tsc` that no `LlmError` construction reports a code that is not assignable to `BuiltinLlmFailureCode`.
diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md
new file mode 100644
index 0000000000..7d7c1fde19
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.2-alpha.2/llm-error-builtin-codes/guide.zh.md
@@ -0,0 +1,18 @@
+---
+kind: upgrade-guide
+description: "`new LlmError` 只接受内置失败 code;其他 code 改用 `LlmError.fromFailure`。"
+---
+
+# `new LlmError` 只接受内置失败 code
+
+[English](guide.md) | 中文
+
+## 变更
+
+在 `@deepseek-ai/dsh-llm` 中,`LlmError` 构造函数的 `code` 参数原为 `string`,现为 `BuiltinLlmFailureCode`:`LLM_FAILURE_CODES` 的键或 `HTTP_`。运行时行为和 `LlmFailure.code` 不变。用其他 code 构造 `LlmError` 的 TypeScript 代码(例如仓库外的提供方适配器或测试替身)将无法通过编译。
+
+## 迁移
+
+1. 把每个 `code` 不是内置值的 `new LlmError(message, code, options)` 改为 `LlmError.fromFailure({ message, code, ...facts }, { cause })`,其中 `facts` 包含需要的 `status`、`providerRetryAfterMs`、`requestId` 或 `offloadImages`。
+2. 重新抛出从流中收到的失败时,使用 `LlmError.fromFailure(finish.failure)`,而不是把 `finish.failure.code` 传给构造函数。
+3. 用 `tsc` 确认没有任何 `LlmError` 构造报告 code 不能赋值给 `BuiltinLlmFailureCode`。
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index ca0741bab3..b498c4ba84 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -21,8 +21,8 @@
en: f4817eb2e3537ed9
zh: e589c193fa85473f
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider:
- en: 9205757693f228f7
- zh: 4b6553f78bb17f22
+ en: 03a9953b8aa0c97a
+ zh: f3a5bcaa34193bcd
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
en: c0b04b2b11af60d3
zh: d195eec281e445cb
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index 3ef85738c8..84c0e2d678 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits.
-The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses, such as a fractional retry count, a delay that is not positive, or a jitter ratio above 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or the schema's first mode when none is inherited, and Apply saves that mode. Switching modes keeps only the fields the new mode declares. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
+The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from the choices the `retryableCodes` schema publishes, shown in words (for example **Rate limited** for `RATE_LIMIT`), plus any inherited or stored code outside them, shown verbatim. A third-party code is typed into **Custom error code** and added with **Add** or Enter, which never submits the card: surrounding whitespace is trimmed, case is kept, an empty entry is ignored, and a code already listed is selected instead of duplicated. An added code is selected in the draft only, stays listed after it is unchecked during the same edit, and appears again on reopening once saved; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses, such as a fractional retry count, a delay that is not positive, or a jitter ratio above 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or the schema's first mode when none is inherited, and Apply saves that mode. Switching modes keeps only the fields the new mode declares. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy.
The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`.
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index 62da88c70e..9fb8a65ef5 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -55,7 +55,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。
-同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用,例如小数重试次数、非正延迟或大于 1 的抖动比例。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示 schema 中的第一个模式,应用时会保存该模式。切换模式时只保留新模式声明的字段。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
+同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从 `retryableCodes` schema 发布的选项中勾选,选项以文字显示(例如 `RATE_LIMIT` 显示为**请求限流**),选项之外继承或已存储的错误码也会列出并原样显示。第三方错误码可在**自定义错误码**中输入,点击**添加**或按 Enter 加入,Enter 不会提交卡片:首尾空白会去除,大小写保留,空输入被忽略,已列出的错误码只会被勾选而不会重复添加。添加的错误码只在草稿中被勾选,本次编辑中取消勾选后仍保留在列表里,保存后重新打开会再次显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用,例如小数重试次数、非正延迟或大于 1 的抖动比例。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示 schema 中的第一个模式,应用时会保存该模式。切换模式时只保留新模式声明的字段。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。
`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。
diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
index ddc20c575d..eb25fd570c 100644
--- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css
+++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css
@@ -681,6 +681,13 @@
gap: 6px 16px;
}
+/* Custom error-code input beside its Add action. */
+.retryCodeAdd {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+}
+
/* One backoff column wide, so the count lines up with the delay fields below. */
.retryCount {
width: max(112px, calc((100% - 16px) / 3));
diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
index 6097f9c7b2..72a963f7b4 100644
--- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx
@@ -33,12 +33,8 @@ const LABELS: ReadonlyMap = new Map([
/** Hints shown under the mode select, by mode. */
const MODE_HINTS: ReadonlyMap = new Map([['always', 'retryAlwaysHint']])
-/**
- * Stable `LlmFailure` codes offered in a code picker, with their labels: the
- * transient defaults of `dsh-llm`, then the other request failures a retry can
- * plausibly recover. Inherited and stored codes outside this list stay listed after them.
- */
-const CODE_CHOICES: ReadonlyMap = new Map([
+/** Labels of known failure codes; a code without one shows verbatim. */
+const CODE_LABELS: ReadonlyMap = new Map([
['EMPTY_RESPONSE', 'retryCodeEmptyResponse'],
['RATE_LIMIT', 'retryCodeRateLimit'],
['SERVER', 'retryCodeServer'],
@@ -70,6 +66,15 @@ function modeOf(branch: SchemaNode): string | undefined {
return typeof mode === 'string' ? mode : undefined
}
+/** The constant choices a list field's item schema publishes, in schema order. */
+function choicesOf(node: SchemaNode): string[] {
+ // A union item always carries `list`; any other item publishes no choices.
+ return (node.inner?.list ?? []).flatMap((branch) => {
+ const value: unknown = branch.type === 'const' ? branch.value : undefined
+ return typeof value === 'string' ? [value] : []
+ })
+}
+
/** The editable fields of one object node, in schema order, without the `mode` discriminant. */
function fieldsOf(node: SchemaNode): [string, SchemaNode][] {
/* v8 ignore next -- object schema nodes always carry `dict` */
@@ -246,6 +251,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
const mode = branch === undefined ? undefined : String(current['mode'])
// Raw text keeps partial input such as `0.` visible while it is typed.
const [texts, setTexts] = useState>({})
+ const [added, setAdded] = useState>({})
// A nested fold starts open only when the draft already customizes it.
const [openFolds] = useState(() => new Set(Object.keys(current).filter(key => isRecord(current[key]))))
const label = (key: string): string => {
@@ -279,13 +285,27 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
)
}
- const codesField = (root: SchemaNode, path: readonly string[]): ReactNode => {
+ const codesField = (root: SchemaNode, path: readonly string[], node: SchemaNode): ReactNode => {
+ const id = path.join('.')
const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(code => typeof code === 'string') : []
const inherited = strings(defaults.value(root, path))
const storedCodes = fieldAt(current, path)
const selected = Array.isArray(storedCodes) ? strings(storedCodes) : inherited
- const choices = [...new Set([...CODE_CHOICES.keys(), ...inherited, ...selected])]
+ // Codes added during this edit stay listed after they are unchecked.
+ const choices = [...new Set([...choicesOf(node), ...inherited, ...selected, ...added[id] ?? []])]
const invalid = invalidAt(path)
+ const select = (next: readonly string[]): void => {
+ // Selecting exactly the inherited codes keeps the field inherited.
+ const inherits = next.length === inherited.length && next.every(value => inherited.includes(value))
+ props.onChange(withField(current, path, inherits ? undefined : [...next]))
+ }
+ const draftCode = (texts[id] ?? '').trim()
+ const add = (): void => {
+ if (draftCode.length === 0) return
+ setAdded(previous => ({ ...previous, [id]: [...new Set([...previous[id] ?? [], draftCode])] }))
+ setTexts(previous => ({ ...previous, [id]: '' }))
+ if (!selected.includes(draftCode)) select([...selected, draftCode])
+ }
return (
{label(path[path.length - 1] as string)}
{choices.map((code) => {
- const labelKey = CODE_CHOICES.get(code)
+ const labelKey = CODE_LABELS.get(code)
return (
{
- const next = choices.filter(value => value === code ? checked : selected.includes(value))
- // Selecting exactly the inherited codes keeps the field inherited.
- const inherits = next.length === inherited.length && next.every(value => inherited.includes(value))
- props.onChange(withField(current, path, inherits ? undefined : next))
+ select(choices.filter(value => value === code ? checked : selected.includes(value)))
}}
/>
)
})}
+
+ {
+ const value = event.target.value
+ setTexts(previous => ({ ...previous, [id]: value }))
+ }}
+ onKeyDown={(event) => {
+ // Enter that confirms an IME composition belongs to the composition.
+ if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
+ // Enter adds the code; it never submits the settings form.
+ event.preventDefault()
+ add()
+ }}
+ />
+
+ {t('retryCodeAdd')}
+
+
)
}
@@ -323,7 +369,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
if (node.type === 'number') {
return numberField(root, path, node, base.length === 0 ? `${styles['modelField']} ${styles['retryCount']}` : styles['modelField'])
}
- if (node.type === 'array') return codesField(root, path)
+ if (node.type === 'array') return codesField(root, path, node)
if (node.type === 'object') {
return (
diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts
index eb18e17195..65a21bdf66 100644
--- a/packages/client/ui-settings-models/src/client/locales.ts
+++ b/packages/client/ui-settings-models/src/client/locales.ts
@@ -71,6 +71,8 @@ export const en = {
retryCodeStreamClosed: 'Stream closed early',
retryCodeMalformedResponse: 'Malformed response',
retryCodeQuota: 'Quota exceeded',
+ retryCodeCustom: 'Custom error code',
+ retryCodeAdd: 'Add',
retryInitialDelay: 'Initial delay (ms)',
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
@@ -242,6 +244,8 @@ export const zh: { [Key in keyof typeof en]: string } = {
retryCodeStreamClosed: '流提前结束',
retryCodeMalformedResponse: '响应格式错误',
retryCodeQuota: '配额已用尽',
+ retryCodeCustom: '自定义错误码',
+ retryCodeAdd: '添加',
retryInitialDelay: '初始延迟 (ms)',
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
@@ -410,6 +414,8 @@ export const ja = {
'retryCodeStreamClosed': 'ストリームの途中終了',
'retryCodeMalformedResponse': '不正なレスポンス',
'retryCodeQuota': 'クォータ超過',
+ 'retryCodeCustom': 'カスタムエラーコード',
+ 'retryCodeAdd': '追加',
'retryInitialDelay': '初期遅延 (ms)',
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
@@ -578,6 +584,8 @@ export const es = {
retryCodeStreamClosed: 'Flujo cerrado antes de tiempo',
retryCodeMalformedResponse: 'Respuesta mal formada',
retryCodeQuota: 'Cuota agotada',
+ retryCodeCustom: 'Código de error personalizado',
+ retryCodeAdd: 'Añadir',
retryInitialDelay: 'Retraso inicial (ms)',
retryMaxDelay: 'Retraso máximo (ms)',
retryJitter: 'Proporción de jitter',
@@ -746,6 +754,8 @@ export const fr = {
retryCodeStreamClosed: 'Flux interrompu',
retryCodeMalformedResponse: 'Réponse mal formée',
retryCodeQuota: 'Quota dépassé',
+ retryCodeCustom: "Code d'erreur personnalisé",
+ retryCodeAdd: 'Ajouter',
retryInitialDelay: 'Délai initial (ms)',
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
@@ -914,6 +924,8 @@ export const de = {
retryCodeStreamClosed: 'Stream vorzeitig beendet',
retryCodeMalformedResponse: 'Fehlerhafte Antwort',
retryCodeQuota: 'Kontingent erschöpft',
+ retryCodeCustom: 'Benutzerdefinierter Fehlercode',
+ retryCodeAdd: 'Hinzufügen',
retryInitialDelay: 'Anfangsverzögerung (ms)',
retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
@@ -1082,6 +1094,8 @@ export const ru = {
retryCodeStreamClosed: 'Поток прерван',
retryCodeMalformedResponse: 'Некорректный ответ',
retryCodeQuota: 'Квота исчерпана',
+ retryCodeCustom: 'Пользовательский код ошибки',
+ retryCodeAdd: 'Добавить',
retryInitialDelay: 'Начальная задержка (ms)',
retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
@@ -1250,6 +1264,8 @@ export const pt = {
retryCodeStreamClosed: 'Fluxo encerrado cedo',
retryCodeMalformedResponse: 'Resposta malformada',
retryCodeQuota: 'Cota esgotada',
+ retryCodeCustom: 'Código de erro personalizado',
+ retryCodeAdd: 'Adicionar',
retryInitialDelay: 'Atraso inicial (ms)',
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
index 344f245c7a..2771b85a4f 100644
--- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
+++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx
@@ -22,7 +22,11 @@ const RetryPolicy = Schema.union([
Schema.object({
mode: Schema.const('normal').required(),
maxRetries: Schema.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(5),
- retryableCodes: Schema.array(Schema.string().min(1)).min(1).default(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']),
+ retryableCodes: Schema.array(Schema.union([
+ ...['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'STREAM_CLOSED', 'MALFORMED_RESPONSE', 'QUOTA']
+ .map(code => Schema.const(code)),
+ Schema.string().min(1),
+ ])).min(1).default(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']),
backoff,
}),
Schema.object({ mode: Schema.const('always').required(), backoff }),
@@ -79,6 +83,8 @@ const CODE_LABELS: Record = {
TRANSPORT: en.retryCodeTransport,
STREAM_CLOSED: en.retryCodeStreamClosed,
}
+const customCode = () => screen.getByRole('textbox', { name: en.retryCodeCustom })
+const addCode = () => screen.getByRole('button', { name: en.retryCodeAdd })
const code_ = (code: string) => screen.getByRole('checkbox', { name: CODE_LABELS[code] ?? code })
const apply = () => screen.getByText(en.apply)
@@ -298,7 +304,8 @@ describe('retry policy fields', () => {
expect(input('attempts').inputMode).toBe('numeric')
expect(input(en.retryMaxDelay).inputMode).toBe('decimal')
expect(input(en.retryMaxDelay).placeholder).toBe('')
- expect(code_('SERVER').checked).toBe(false)
+ expect(screen.queryAllByRole('checkbox')).toHaveLength(0)
+ expect(customCode()).toBeTruthy()
expect(screen.queryByText('note')).toBeNull()
expect(screen.queryByLabelText(en.retryInitialDelay)).toBeNull()
fireEvent.change(input('attempts'), { target: { value: '0' } })
@@ -313,6 +320,49 @@ describe('retry policy fields', () => {
])
})
+ it('adds a custom code, ignores blanks and duplicates, keeps it after unchecking, and saves it', async () => {
+ const { writeSettings, onClose } = setup(deepSeekNamespace())
+ fireEvent.change(mode(), { target: { value: 'normal' } })
+ expect(code_('STREAM_CLOSED').checked).toBe(false)
+ expect(addCode().disabled).toBe(true)
+ fireEvent.change(customCode(), { target: { value: ' ' } })
+ expect(addCode().disabled).toBe(true)
+ fireEvent.keyDown(customCode(), { key: 'Enter' })
+ expect(screen.getAllByRole('checkbox')).toHaveLength(8)
+ fireEvent.change(customCode(), { target: { value: ' Overloaded_529 ' } })
+ fireEvent.click(addCode())
+ expect(customCode().value).toBe('')
+ expect(code_('Overloaded_529').checked).toBe(true)
+ expect(code_('SERVER').checked).toBe(true)
+ fireEvent.change(customCode(), { target: { value: 'Overloaded_529' } })
+ const enter = fireEvent.keyDown(customCode(), { key: 'Enter' })
+ expect(enter).toBe(false)
+ fireEvent.keyDown(customCode(), { key: 'a' })
+ expect(screen.getAllByRole('checkbox', { name: 'Overloaded_529' })).toHaveLength(1)
+ expect(onClose).not.toHaveBeenCalled()
+ fireEvent.click(code_('Overloaded_529'))
+ expect(code_('Overloaded_529').checked).toBe(false)
+ fireEvent.click(code_('Overloaded_529'))
+ fireEvent.change(customCode(), { target: { value: 'COMPOSING' } })
+ fireEvent.keyDown(customCode(), { key: 'Enter', isComposing: true })
+ expect(screen.queryByRole('checkbox', { name: 'COMPOSING' })).toBeNull()
+ fireEvent.change(customCode(), { target: { value: 'SERVER' } })
+ fireEvent.keyDown(customCode(), { key: 'Enter' })
+ expect(screen.getAllByRole('checkbox', { name: en.retryCodeServer })).toHaveLength(1)
+ fireEvent.click(apply())
+ await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
+ expect(writeSettings.mock.calls[0]?.[1]).toEqual([
+ { op: 'set', path: ['retryPolicy', 'mode'], value: 'normal' },
+ { op: 'set', path: ['retryPolicy', 'retryableCodes'], value: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'Overloaded_529'] },
+ ])
+ })
+
+ it('lists a saved custom code when the editor reopens', () => {
+ setup(deepSeekNamespace({ user: { retryPolicy: { mode: 'normal', retryableCodes: ['SERVER', 'Overloaded_529'] } } }))
+ expect(code_('Overloaded_529').checked).toBe(true)
+ expect(code_('RATE_LIMIT').checked).toBe(false)
+ })
+
it('offers only Default when the retry schema declares no mode branches', () => {
const namespace = deepSeekNamespace()
const policy = Schema.union([Schema.object({ backoff: Schema.object({}) }), Schema.const(false)])
diff --git a/packages/compaction/compaction-basic/src/summarizer.ts b/packages/compaction/compaction-basic/src/summarizer.ts
index 2d258fa410..6220a53b25 100644
--- a/packages/compaction/compaction-basic/src/summarizer.ts
+++ b/packages/compaction/compaction-basic/src/summarizer.ts
@@ -198,7 +198,7 @@ function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
case 'error':
case 'aborted': {
- return new LlmError(finish.failure.message, finish.failure.code, finish.failure)
+ return LlmError.fromFailure(finish.failure)
}
case 'max-tokens': {
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index c4daa50c47..781ae7f9c6 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -558,7 +558,7 @@ export class ReactLoopAgent implements Agent {
)
signal.throwIfAborted()
if (action?.kind !== 'retry') {
- throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
+ throw LlmError.fromFailure(finish.failure)
}
continue
}
diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts
index 57a9887b3a..82602871ad 100644
--- a/packages/core/agent-loop/tests/coverage-edges.spec.ts
+++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts
@@ -434,7 +434,7 @@ describe('recovery without a retry action', () => {
it('a completed recovery that returns no action leaves the failed turn terminal', async () => {
const { LlmError } = await import('@deepseek-ai/dsh-llm')
const adapter = new MockAdapter([
- () => { throw new LlmError('down', 'SERVICE_UNAVAILABLE') },
+ () => { throw LlmError.fromFailure({ message: 'down', code: 'SERVICE_UNAVAILABLE' }) },
])
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('recovery-no-retry'), { provider: 'mock', model: 'mock' })
diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts
index 170de98fb6..ce4d405aa2 100644
--- a/packages/core/agent-loop/tests/loop.spec.ts
+++ b/packages/core/agent-loop/tests/loop.spec.ts
@@ -865,7 +865,7 @@ describe('agent loop', () => {
})
it('restores runtime context during a retry without admitting the user batch twice', async () => {
- const adapter = new MockAdapter([textResponse('one'), () => { throw new LlmError('overflow', 'CONTEXT_LENGTH') }, textResponse('two')])
+ const adapter = new MockAdapter([textResponse('one'), () => { throw LlmError.fromFailure({ message: 'overflow', code: 'CONTEXT_LENGTH' }) }, textResponse('two')])
const ctx = await harness(adapter)
try {
ctx.systemPrompt.context({ name: 'directory', order: 0, required: true, text: 'Current working directory: /workspace.' })
diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts
index 5287cb4deb..90799668da 100644
--- a/packages/core/agent-loop/tests/request-error.spec.ts
+++ b/packages/core/agent-loop/tests/request-error.spec.ts
@@ -27,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise {
function fail(message: string, code: string): () => never {
return () => {
- throw new LlmError(message, code)
+ throw LlmError.fromFailure({ message: message, code: code })
}
}
@@ -38,7 +38,7 @@ describe('agent/request-error', () => {
const agent = await ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request', () => {
- throw new LlmError('middleware failed', 'MIDDLEWARE')
+ throw LlmError.fromFailure({ message: 'middleware failed', code: 'MIDDLEWARE' })
})
ctx.on('agent/request-error', async () => {
recoveries += 1
diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
index 8b8034d93d..0ede0d9d08 100644
--- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts
+++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
@@ -534,7 +534,7 @@ describe('request stability across the loop', () => {
it('starts a new request series when compaction rewrites a retry in the same step', async () => {
const adapter = new MockAdapter([
- () => { throw new LlmError('request is too large', 'CONTEXT_LENGTH') },
+ () => { throw LlmError.fromFailure({ message: 'request is too large', code: 'CONTEXT_LENGTH' }) },
textResponse('recovered'),
])
const ctx = await harness(adapter)
diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
index 206648bb9c..5f31eb7c0e 100644
--- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
+++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts
@@ -287,7 +287,7 @@ describe('prepared-route prompt admission', () => {
})
it.each([false, true])('reconciles compaction retries without replaying admission, older tail survives=%s', async (retainOlder) => {
- const overflow = () => { throw new LlmError('context window exceeded', 'CONTEXT_LENGTH') }
+ const overflow = () => { throw LlmError.fromFailure({ message: 'context window exceeded', code: 'CONTEXT_LENGTH' }) }
const adapter = new MockAdapter([textResponse('one'), textResponse('two'), overflow, overflow, textResponse('done')])
const h = await harness(adapter)
const resolve = vi.spyOn(adapter, 'resolveModel')
diff --git a/packages/credentials/deepseek-account/tests/account-tasks.spec.ts b/packages/credentials/deepseek-account/tests/account-tasks.spec.ts
index c2e0ddc9d9..14b9653b33 100644
--- a/packages/credentials/deepseek-account/tests/account-tasks.spec.ts
+++ b/packages/credentials/deepseek-account/tests/account-tasks.spec.ts
@@ -29,7 +29,7 @@ it('cancels running account tasks with their inboxes retained and detaches with
ctx.on('deepseek-account/model-sign-in-required', notice)
const report = (error: unknown) => { ctx.emit('agent/error', { agent: {} as never, turn: 0, step: 0, error }) }
report(new Error('offline'))
- report(new LlmError('API key invalid', 'INVALID_API_KEY'))
+ report(LlmError.fromFailure({ message: 'API key invalid', code: 'INVALID_API_KEY' }))
expect(notice).not.toHaveBeenCalled()
report(new LlmError('Sign in required', 'ACCOUNT_SIGN_IN_REQUIRED'))
expect(notice).toHaveBeenCalledTimes(1)
diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts
index df35b6ce3b..a6129f26fd 100644
--- a/packages/llm/llm-deepseek/src/adapter.ts
+++ b/packages/llm/llm-deepseek/src/adapter.ts
@@ -162,7 +162,7 @@ export class DeepSeekAdapter extends LlmAdapt
if (await files.retry(detail)) continue
const failure = providerError(raw, response.status, response.headers)
const message = files.errorMessage(response.status, failure.message, detail)
- throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) })
+ throw LlmError.fromFailure({ ...failure.failure, message }, { cause: new Error(text) })
}
await extensions.accept()
if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE')
diff --git a/packages/llm/llm-deepseek/src/replay.ts b/packages/llm/llm-deepseek/src/replay.ts
index f285ebaeae..7d00df728d 100644
--- a/packages/llm/llm-deepseek/src/replay.ts
+++ b/packages/llm/llm-deepseek/src/replay.ts
@@ -1,7 +1,7 @@
/** Minimal native thinking metadata; durable Harness blocks own all response text. */
import { LlmError } from '@deepseek-ai/dsh-llm'
-import type { Message, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
+import type { BuiltinLlmFailureCode, Message, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
/** Index-aligned metadata retained alongside each emitted Harness block. */
export interface ReplayBlock {
@@ -14,7 +14,7 @@ export interface ReplayBlock {
* @param code - owning failure category.
* @returns the validated object.
*/
-export function object(value: unknown, code = 'MALFORMED_RESPONSE'): Record {
+export function object(value: unknown, code: BuiltinLlmFailureCode = 'MALFORMED_RESPONSE'): Record {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new LlmError('DeepSeek Messages expected a JSON object', code)
}
diff --git a/packages/llm/llm-deepseek/src/transport.ts b/packages/llm/llm-deepseek/src/transport.ts
index 935e8855df..9ff4eef577 100644
--- a/packages/llm/llm-deepseek/src/transport.ts
+++ b/packages/llm/llm-deepseek/src/transport.ts
@@ -1,6 +1,7 @@
/** Normalize HTTP and in-band Messages errors into provider-neutral failures. */
import { isContextWindowExceededError, isQuotaExceededError, LlmError, ProviderRequestId } from '@deepseek-ai/dsh-llm'
+import type { BuiltinLlmFailureCode } from '@deepseek-ai/dsh-llm'
/** Read only provider error fields used by bounded Files recovery.
* @param raw - decoded HTTP error response.
@@ -25,7 +26,7 @@ export function providerError(raw: unknown, status: number | undefined, headers?
const message = typeof error.message === 'string' ? error.message : `DeepSeek Messages request failed (${status ?? 'stream error'})`
const type = typeof error.type === 'string' ? error.type : ''
const detail = `${type} ${typeof error.code === 'string' ? error.code : ''} ${message}`
- let code: string
+ let code: BuiltinLlmFailureCode
if (status === 401 || status === 403 || ['authentication_error', 'permission_error'].includes(type)) code = 'AUTH'
else if (isQuotaExceededError(detail) || status === 402) code = 'QUOTA'
else if (status === 429 || type === 'rate_limit_error') code = 'RATE_LIMIT'
diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts
index 17b7158135..a98b0b9c4e 100644
--- a/packages/llm/llm-pi-ai/src/stream.ts
+++ b/packages/llm/llm-pi-ai/src/stream.ts
@@ -10,7 +10,7 @@
import { brandString } from '@deepseek-ai/dsh-brand'
import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
-import type { FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { BuiltinLlmFailureCode, FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { isContextOverflow } from '@earendil-works/pi-ai/utils/overflow'
import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai'
import { toPiReplayState } from './replay.ts'
@@ -39,7 +39,7 @@ export function mapUsage(usage: PiUsage): TokenUsage {
// wrapper a bare `terminated`, so we are left pattern-matching terse words here.
// If pi-ai ever forwards the original Error (or a fetch/dispatcher hook that lets
// us capture the cause ourselves), classify on `code`/`cause` instead of text.
-function classifyPiAiError(message: string): string {
+function classifyPiAiError(message: string): BuiltinLlmFailureCode {
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE
if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT'
@@ -110,15 +110,15 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number)
case 'toolUse': return { kind: 'tool-calls' }
case 'pending': return {
kind: 'error',
- failure: { message: `pi-ai stream for model "${message.model}" ended pending`, code: 'PI_AI_ERROR' },
+ failure: { message: `pi-ai stream for model "${message.model}" ended pending`, code: 'PI_AI_ERROR' satisfies BuiltinLlmFailureCode },
}
case 'deferred': return {
kind: 'error',
- failure: { message: `pi-ai deferred response for model "${message.model}" is not supported`, code: 'PI_AI_ERROR' },
+ failure: { message: `pi-ai deferred response for model "${message.model}" is not supported`, code: 'PI_AI_ERROR' satisfies BuiltinLlmFailureCode },
}
case 'aborted': return {
kind: 'aborted',
- failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' },
+ failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' satisfies BuiltinLlmFailureCode },
}
case 'error': {
const text = message.errorMessage ?? 'pi-ai stream error'
diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts
index d14f120205..4810a236c2 100644
--- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts
+++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts
@@ -1055,7 +1055,7 @@ describe('provider profile lifecycle', () => {
await expect((async () => {
for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ }
})()).rejects.toMatchObject({ code: 'NO_ADAPTER' })
- expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
+ expect(LlmError.fromFailure({ message: 'x', code: 'X' })).toBeInstanceOf(Error)
})
it('rejects unsupported or unresolved image input before provider I/O', async () => {
diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml
index 16f199923c..26f7ee0748 100644
--- a/packages/llm/llm/README.i18n.yaml
+++ b/packages/llm/llm/README.i18n.yaml
@@ -27,8 +27,8 @@
en: dc61eb86585f6e0d
zh: 9c0d6b240dfd2cda
/deepseek-ai-dsh-llm/use-this-package/failures-and-recovery:
- en: 311d8c92447b7094
- zh: 68cf5cb1c3853cc1
+ en: 47f4dab3c377a4cd
+ zh: 49ac1c366de19136
/deepseek-ai-dsh-llm/understand-the-implementation:
en: 9630d7e26193a512
zh: 94222c2a487c4b8c
diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md
index e635e4f53c..ad81a943ca 100644
--- a/packages/llm/llm/README.md
+++ b/packages/llm/llm/README.md
@@ -92,7 +92,7 @@ const configure = (maxTokens: number): ConfigureCall =>
### Failures and recovery
-Every stream ends in exactly one terminal `finish` chunk: `{ kind: 'error', failure }` on failure, `{ kind: 'aborted', failure }` on cancellation. Failures carry stable codes such as `NO_ADAPTER`, `MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`, and `CONTEXT_WINDOW_EXCEEDED`; consumers route on the code, never on message text. `QUOTA` is provider-neutral exhaustion, while `ACCOUNT_QUOTA` is reserved for a first-party account balance that the current product can replenish. A request naming an unregistered provider fails with `NO_ADAPTER`, and a malformed credential fails with `INVALID_CREDENTIAL` instead of surfacing as an opaque fetch error. This service never re-runs a request: retrying is the job of `dsh-llm-retry` at the agent's failed-step extension point.
+Every stream ends in exactly one terminal `finish` chunk: `{ kind: 'error', failure }` on failure, `{ kind: 'aborted', failure }` on cancellation. Failures carry stable codes such as `NO_ADAPTER`, `MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`, and `CONTEXT_WINDOW_EXCEEDED`; consumers route on the code, never on message text. `LLM_FAILURE_CODES` defines every code the built-in providers and this runtime raise; `new LlmError` accepts only those codes or `HTTP_`, and `LlmError.fromFailure` re-raises an observed failure whose code another provider chose. Codes marked `retryChoice` are the choices a provider `retryPolicy.retryableCodes` schema publishes to settings forms; being a choice does not add a code to the default retried set. `QUOTA` is provider-neutral exhaustion, while `ACCOUNT_QUOTA` is reserved for a first-party account balance that the current product can replenish. A request naming an unregistered provider fails with `NO_ADAPTER`, and a malformed credential fails with `INVALID_CREDENTIAL` instead of surfacing as an opaque fetch error. This service never re-runs a request: retrying is the job of `dsh-llm-retry` at the agent's failed-step extension point.
-----
diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md
index 4e07a748f0..078780bf8b 100644
--- a/packages/llm/llm/README.zh.md
+++ b/packages/llm/llm/README.zh.md
@@ -92,7 +92,7 @@ const configure = (maxTokens: number): ConfigureCall =>
### 失败与恢复
-每个流都恰好以一个终止 `finish` 分片结束:失败为 `{ kind: 'error', failure }`,取消为 `{ kind: 'aborted', failure }`。失败携带稳定 code,如 `NO_ADAPTER`、`MISSING_CREDENTIAL`、`AUTH`、`RATE_LIMIT` 与 `CONTEXT_WINDOW_EXCEEDED`;消费方依据 code 路由,绝不解析消息文本。`QUOTA` 表示提供方中立的额度耗尽,`ACCOUNT_QUOTA` 专用于当前产品能够充值的第一方账号余额不足。点名未注册提供方的请求会以 `NO_ADAPTER` 失败,格式错误的凭据会以 `INVALID_CREDENTIAL` 失败,而不是表现为不透明的 fetch 错误。本服务从不自行重跑请求:重试是 `dsh-llm-retry` 在 agent 失败步骤扩展点上的职责。
+每个流都恰好以一个终止 `finish` 分片结束:失败为 `{ kind: 'error', failure }`,取消为 `{ kind: 'aborted', failure }`。失败携带稳定 code,如 `NO_ADAPTER`、`MISSING_CREDENTIAL`、`AUTH`、`RATE_LIMIT` 与 `CONTEXT_WINDOW_EXCEEDED`;消费方依据 code 路由,绝不解析消息文本。`LLM_FAILURE_CODES` 定义内置提供方与本运行时会抛出的全部 code;`new LlmError` 只接受这些 code 或 `HTTP_`,`LlmError.fromFailure` 用于重新抛出 code 由其他提供方决定的已观察失败。标记了 `retryChoice` 的 code 是提供方 `retryPolicy.retryableCodes` schema 向设置表单发布的选项;成为选项不会把它加入默认重试集合。`QUOTA` 表示提供方中立的额度耗尽,`ACCOUNT_QUOTA` 专用于当前产品能够充值的第一方账号余额不足。点名未注册提供方的请求会以 `NO_ADAPTER` 失败,格式错误的凭据会以 `INVALID_CREDENTIAL` 失败,而不是表现为不透明的 fetch 错误。本服务从不自行重跑请求:重试是 `dsh-llm-retry` 在 agent 失败步骤扩展点上的职责。
-----
diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts
index 98dba446d1..1a8a8cd30d 100644
--- a/packages/llm/llm/src/adapter-failure.ts
+++ b/packages/llm/llm/src/adapter-failure.ts
@@ -5,6 +5,7 @@
*/
import { HarnessError } from './error.ts'
+import type { BuiltinLlmFailureCode } from './error.ts'
import type { LlmFailure } from './types.ts'
/**
@@ -16,7 +17,7 @@ import type { LlmFailure } from './types.ts'
export function normalizeLlmFailure(value: unknown): LlmFailure {
const error = value instanceof Error
? value
- : new HarnessError(thrownMessage(value), 'UNKNOWN', { cause: value })
+ : new HarnessError(thrownMessage(value), 'UNKNOWN' satisfies BuiltinLlmFailureCode, { cause: value })
// Cross-package copies preserve own data but not class identity. Trust the
// carried facts only when both own properties agree after validation.
const carried = ownFailureSnapshot(error)
@@ -103,5 +104,5 @@ function errorMessage(error: Error): string {
/** Trust only Harness-owned codes; third-party SDK codes are not our taxonomy. */
function harnessErrorCode(error: Error): string {
- return error instanceof HarnessError ? error.code : 'UNKNOWN'
+ return error instanceof HarnessError ? error.code : 'UNKNOWN' satisfies BuiltinLlmFailureCode
}
diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts
index 9be7cf9c00..dd49f5f1be 100644
--- a/packages/llm/llm/src/error.ts
+++ b/packages/llm/llm/src/error.ts
@@ -21,14 +21,83 @@ export class HarnessError extends Error {
}
}
+/**
+ * Every failure code the built-in providers and the LLM runtime raise. An entry
+ * marked `retryChoice` is offered as a choice for a provider retry policy's
+ * `retryableCodes`; being a choice does not make a code retried by default.
+ */
+export const LLM_FAILURE_CODES = {
+ ABORTED: {},
+ ACCOUNT_QUOTA: {},
+ ACCOUNT_SIGN_IN_REQUIRED: {},
+ ACCOUNT_TOKEN_INVALID: {},
+ AUTH: {},
+ CLIENT_UPGRADE_REQUIRED: {},
+ CONTEXT_WINDOW_EXCEEDED: {},
+ DISCOVERY_FAILED: {},
+ DISCOVERY_UNSUPPORTED: {},
+ DUPLICATE_ADAPTER: {},
+ DUPLICATE_DIRECTORY: {},
+ DUPLICATE_DISCOVERY: {},
+ EMPTY_RESPONSE: { retryChoice: true },
+ FILES_API: {},
+ IMAGE_OFFLOAD_REQUIRED: {},
+ INVALID_ADAPTER: {},
+ INVALID_CATALOG: {},
+ INVALID_CONFIG: {},
+ INVALID_CREDENTIAL: {},
+ INVALID_DIRECTORY: {},
+ INVALID_DISCOVERY: {},
+ INVALID_MODEL_CONTEXT: {},
+ INVALID_MODEL_INFO: {},
+ INVALID_MODEL_MAX_TOKENS: {},
+ INVALID_MODEL_REASONING: {},
+ INVALID_PREPARED_CALL: {},
+ INVALID_REPLAY_STATE: {},
+ INVALID_REQUEST: {},
+ INVALID_RESPONSE: {},
+ MALFORMED_RESPONSE: { retryChoice: true },
+ MISSING_CREDENTIAL: {},
+ MODEL_SWITCH_COMPACTION_FAILED: {},
+ MODEL_UNSUPPORTED: {},
+ NO_ADAPTER: {},
+ NO_CREDENTIAL_STORE: {},
+ NO_DISCOVERY: {},
+ PI_AI_ERROR: {},
+ QUOTA: { retryChoice: true },
+ RATE_LIMIT: { retryChoice: true },
+ REGISTRATION_DISPOSED: {},
+ REQUEST_EXTENSION: {},
+ REQUEST_HEADERS: {},
+ SERVER: { retryChoice: true },
+ STREAM_CLOSED: { retryChoice: true },
+ TIMEOUT: { retryChoice: true },
+ TRANSPORT: { retryChoice: true },
+ UNKNOWN: {},
+ UNKNOWN_MODEL: {},
+ UNSTORABLE_PROVIDER_ID: {},
+ UNSUPPORTED_CONTENT: {},
+ UNSUPPORTED_OPTION: {},
+ UNSUPPORTED_REASONING_EFFORT: {},
+} as const satisfies Readonly>
+
+/** A failure code from {@link LLM_FAILURE_CODES}, or an unclassified provider HTTP status. */
+export type BuiltinLlmFailureCode = keyof typeof LLM_FAILURE_CODES | `HTTP_${number}`
+
+/** The {@link LLM_FAILURE_CODES} entries marked as retry-policy choices, in definition order. */
+export const RETRY_CODE_CHOICES: readonly BuiltinLlmFailureCode[] = Object.freeze(
+ (Object.keys(LLM_FAILURE_CODES) as (keyof typeof LLM_FAILURE_CODES)[])
+ .filter(code => 'retryChoice' in LLM_FAILURE_CODES[code]),
+)
+
/** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */
-export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED'
+export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED' satisfies BuiltinLlmFailureCode
/** Canonical provider-neutral code for an exhausted account quota or balance. */
-export const QUOTA_EXCEEDED_CODE = 'QUOTA'
+export const QUOTA_EXCEEDED_CODE = 'QUOTA' satisfies BuiltinLlmFailureCode
/** Account-token quota that can be replenished through the first-party billing page. */
-export const ACCOUNT_QUOTA_EXCEEDED_CODE = 'ACCOUNT_QUOTA'
+export const ACCOUNT_QUOTA_EXCEEDED_CODE = 'ACCOUNT_QUOTA' satisfies BuiltinLlmFailureCode
/**
* Canonical provider-neutral code for a response that completed normally but
@@ -39,7 +108,7 @@ export const ACCOUNT_QUOTA_EXCEEDED_CODE = 'ACCOUNT_QUOTA'
* on. The attempt produced nothing durable, so retry policy treats it as safe
* to repeat.
*/
-export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
+export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE' satisfies BuiltinLlmFailureCode
/**
* Canonical provider-neutral code for a credential that was supplied but
@@ -48,7 +117,7 @@ export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
* rather than supply one. Deliberately outside the default retryable set —
* a malformed credential fails identically on every attempt.
*/
-export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL'
+export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL' satisfies BuiltinLlmFailureCode
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
@@ -172,4 +241,4 @@ export function isHarnessError(value: unknown): value is HarnessError {
* `dsh-compaction-image-offload` records an `image/offload` selection before
* the agent or summarizer retries with freshly derived input.
*/
-export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED'
+export const IMAGE_OFFLOAD_REQUIRED_CODE = 'IMAGE_OFFLOAD_REQUIRED' satisfies BuiltinLlmFailureCode
diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts
index 14be9c87f8..2f4299d8cd 100644
--- a/packages/llm/llm/src/index.ts
+++ b/packages/llm/llm/src/index.ts
@@ -34,6 +34,7 @@ import type { ProviderRequestId } from './brand.ts'
import { callConfigEquals } from './call-config.ts'
import type { ConfigureCall, LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'
+import type { BuiltinLlmFailureCode } from './error.ts'
import { normalizeLlmFailure } from './adapter-failure.ts'
import { normalizeApiKey } from './api-key.ts'
import {
@@ -92,6 +93,9 @@ export interface LlmErrorOptions extends ErrorOptions {
/**
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy.
+ * The constructor takes only a {@link BuiltinLlmFailureCode}; a failure from
+ * another provider, whose code may be any string, is re-raised through
+ * {@link LlmError.fromFailure}.
*/
export class LlmError extends HarnessError {
/** Serializable facts retained beside this live Error. */
@@ -99,10 +103,10 @@ export class LlmError extends HarnessError {
/**
* @param message - non-empty human-readable failure summary.
- * @param code - non-empty stable provider-neutral machine code.
+ * @param code - built-in stable provider-neutral machine code.
* @param options - optional cause and validated serializable provider facts.
*/
- constructor(message: string, code: string, options?: LlmErrorOptions) {
+ constructor(message: string, code: BuiltinLlmFailureCode, options?: LlmErrorOptions) {
if (typeof message !== 'string' || message.length === 0) throw new Error('LlmError message must be a non-empty string')
if (typeof code !== 'string' || code.length === 0) throw new Error('LlmError code must be a non-empty string')
if (options?.status !== undefined
@@ -128,6 +132,17 @@ export class LlmError extends HarnessError {
...options?.offloadImages === undefined ? {} : { offloadImages: options.offloadImages },
})
}
+
+ /**
+ * Raise an observed failure again with its own code, which any provider may have chosen.
+ * @param failure - the serializable failure, typically from a stream's terminal chunk.
+ * @param options - optional cause.
+ * @returns an error carrying the failure's message, code, and provider facts.
+ */
+ static fromFailure(failure: LlmFailure, options?: ErrorOptions): LlmError {
+ // The constructor validates the code at runtime; the built-in type constrains only codes raised here.
+ return new LlmError(failure.message, failure.code as BuiltinLlmFailureCode, { ...failure, ...options })
+ }
}
/**
diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts
index f999a04b63..fc7beb84ba 100644
--- a/packages/llm/llm/src/retry-policy.ts
+++ b/packages/llm/llm/src/retry-policy.ts
@@ -9,13 +9,14 @@
import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import { EMPTY_RESPONSE_CODE } from './error.ts'
+import { EMPTY_RESPONSE_CODE, RETRY_CODE_CHOICES } from './error.ts'
+import type { BuiltinLlmFailureCode } from './error.ts'
const DEFAULT_MAX_RETRIES = 5
const DEFAULT_INITIAL_DELAY_MS = 500
const DEFAULT_MAX_DELAY_MS = 10_000
const DEFAULT_JITTER_RATIO = 0.1
-const DEFAULT_RETRYABLE_CODES = Object.freeze([
+const DEFAULT_RETRYABLE_CODES: readonly BuiltinLlmFailureCode[] = Object.freeze([
EMPTY_RESPONSE_CODE,
'RATE_LIMIT',
'SERVER',
@@ -89,7 +90,10 @@ const backoffSchema: z = z.object({
const normalPolicySchema: z = z.object({
mode: z.const('normal').required(),
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
- retryableCodes: z.array(z.string().min(1)).min(1).default([...DEFAULT_RETRYABLE_CODES]),
+ // The constant branches publish the built-in choices to settings forms; the
+ // string branch admits codes that other providers raise.
+ retryableCodes: z.array(z.union([...RETRY_CODE_CHOICES.map(code => z.const(code)), z.string().min(1)]))
+ .min(1).default([...DEFAULT_RETRYABLE_CODES]),
backoff: backoffSchema,
})
diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts
index 4059858724..d94cad4751 100644
--- a/packages/llm/llm/tests/retry-policy.spec.ts
+++ b/packages/llm/llm/tests/retry-policy.spec.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
+import Schema from '@deepseek-ai/schemastery'
import {
+ LLM_FAILURE_CODES,
resolveRetryPolicy,
+ RETRY_CODE_CHOICES,
RetryPolicySchema,
} from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
@@ -112,4 +115,25 @@ describe('provider retry policy', () => {
retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
})
})
+
+ it('publishes the retry choices through the serialized schema and still admits other codes', () => {
+ const restored = new Schema(JSON.parse(JSON.stringify(RetryPolicySchema.toJSON())) as Schema)
+ const normal = restored.list?.find(branch => branch.dict?.['mode']?.value === 'normal')
+ const item = normal?.dict?.['retryableCodes']?.inner
+ const choices = item?.list?.flatMap((branch): unknown[] => {
+ const value: unknown = branch.value
+ return branch.type === 'const' ? [value] : []
+ })
+ expect(choices).toEqual([...RETRY_CODE_CHOICES])
+ expect(RETRY_CODE_CHOICES).toEqual(
+ Object.keys(LLM_FAILURE_CODES).filter(code => 'retryChoice' in LLM_FAILURE_CODES[code as keyof typeof LLM_FAILURE_CODES]),
+ )
+ expect(restored({ mode: 'normal', retryableCodes: ['SERVER', 'Overloaded_529'] }))
+ .toMatchObject({ retryableCodes: ['SERVER', 'Overloaded_529'] })
+ expect(() => restored({ mode: 'normal', retryableCodes: [''] })).toThrow()
+ // Being a choice does not make a code retried by default.
+ expect(RETRY_CODE_CHOICES).toContain('STREAM_CLOSED')
+ const defaults = resolveRetryPolicy(undefined, 'provider.retryPolicy')
+ expect(defaults.mode === 'normal' ? defaults.retryableCodes : []).not.toContain('STREAM_CLOSED')
+ })
})
diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts
index f320f6fc3c..d4d6ed249f 100644
--- a/packages/llm/llm/tests/service.spec.ts
+++ b/packages/llm/llm/tests/service.spec.ts
@@ -432,7 +432,7 @@ describe('LlmRuntime', () => {
})
it.each(['done', 'value'] as const)('normalizes a throwing IteratorResult.%s getter', async (field) => {
- const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED')
+ const original = LlmError.fromFailure({ message: `${field} getter failed`, code: 'RESULT_GETTER_FAILED' })
const result = field === 'done' ? {} : { done: false }
Object.defineProperty(result, field, { get: () => { throw original } })
let cleanupLookups = 0
@@ -471,7 +471,7 @@ describe('LlmRuntime', () => {
})
it.each(['dispatch', 'iterator'] as const)('normalizes synchronous adapter %s failures', async (boundary) => {
- const original = new LlmError(`${boundary} failed`, 'BOUNDARY_FAILED')
+ const original = LlmError.fromFailure({ message: `${boundary} failed`, code: 'BOUNDARY_FAILED' })
const adapter = new class extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable {
if (boundary === 'dispatch') throw original
@@ -1590,7 +1590,7 @@ describe('LlmRuntime', () => {
})
it('creates LlmError with a code for programmatic handling', () => {
- const err = new LlmError('something went wrong', 'CUSTOM_CODE')
+ const err = LlmError.fromFailure({ message: 'something went wrong', code: 'CUSTOM_CODE' })
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe('LlmError')
expect(err.message).toBe('something went wrong')
diff --git a/packages/test-support/llm-replay/src/index.ts b/packages/test-support/llm-replay/src/index.ts
index d3caea5df0..f96389a426 100644
--- a/packages/test-support/llm-replay/src/index.ts
+++ b/packages/test-support/llm-replay/src/index.ts
@@ -990,7 +990,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined,
if (paceMs > 0) await paceDelay(paceMs, signal)
yield chunk
}
- throw new LlmError(entry.message, entry.code)
+ throw LlmError.fromFailure({ message: entry.message, code: entry.code })
case 'hang':
// Replay a stream that stalls until cancelled (mirrors MockAdapter): one
// chunk, then wait for abort and surface it as the consumer expects.
From d8dae1ad95090143d27a1c6c9f368759e1c188a5 Mon Sep 17 00:00:00 2001
From: Turtle
Date: Tue, 3 Nov 2026 16:48:18 +0800
Subject: [PATCH 012/106] docs(llm): raise third-party adapter codes through
LlmError.fromFailure
---
docs/user/develop/practice/llm-adapter.i18n.yaml | 4 ++--
docs/user/develop/practice/llm-adapter.md | 4 ++--
docs/user/develop/practice/llm-adapter.zh.md | 4 ++--
packages/workflow/workflow-ptc/src/guest-source.ts | 2 +-
4 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml
index 3bfd570184..5edeb61005 100644
--- a/docs/user/develop/practice/llm-adapter.i18n.yaml
+++ b/docs/user/develop/practice/llm-adapter.i18n.yaml
@@ -30,5 +30,5 @@
en: e05d4b0a901e8ef0
zh: 9516cebb3e5c3692
/llm-adapters/error-handling:
- en: d54065c52caf7910
- zh: 79b3ebc22a38286d
+ en: 08670c0bc7b8f494
+ zh: 37312af73d0acf91
diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md
index 32e412b10a..59eeb0078c 100644
--- a/docs/user/develop/practice/llm-adapter.md
+++ b/docs/user/develop/practice/llm-adapter.md
@@ -153,7 +153,7 @@ Compare the two shipped adapters to see the same harness contract implemented ov
## Error handling
-Adapters throw transport and protocol failures as `LlmError` values with stable codes. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`.
+Adapters throw transport and protocol failures as `LlmError` values with stable codes. `new LlmError` accepts only the built-in codes of `LLM_FAILURE_CODES`; an adapter's own code goes through `LlmError.fromFailure`. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`.
```ts
import {
@@ -180,7 +180,7 @@ class HttpAdapter extends LlmAdapter {
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
- throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
+ throw LlmError.fromFailure({ message: `Provider API error: ${response.status}`, code: 'PROVIDER_HTTP_ERROR', status: response.status })
}
// A real adapter parses the response and emits the complete chunk sequence.
yield { type: 'finish', reason: { kind: 'stop' } }
diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md
index bfa9842ce5..8ad0550881 100644
--- a/docs/user/develop/practice/llm-adapter.zh.md
+++ b/docs/user/develop/practice/llm-adapter.zh.md
@@ -153,7 +153,7 @@ ctx.llm.registerAdapter(['my-provider'], adapter)
## 错误处理
-适配器应通过带稳定 code 的 `LlmError` 抛出传输和协议故障;agent loop(智能体循环)会保留该错误及其 code,用于诊断和策略处理。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。
+适配器应通过带稳定 code 的 `LlmError` 抛出传输和协议故障。`new LlmError` 只接受 `LLM_FAILURE_CODES` 中的内置 code;适配器自己的 code 通过 `LlmError.fromFailure` 抛出。agent loop(智能体循环)会保留该错误及其 code,用于诊断和策略处理。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。
```ts
import {
@@ -180,7 +180,7 @@ class HttpAdapter extends LlmAdapter {
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
- throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
+ throw LlmError.fromFailure({ message: `Provider API error: ${response.status}`, code: 'PROVIDER_HTTP_ERROR', status: response.status })
}
// A real adapter parses the response and emits the complete chunk sequence.
yield { type: 'finish', reason: { kind: 'stop' } }
diff --git a/packages/workflow/workflow-ptc/src/guest-source.ts b/packages/workflow/workflow-ptc/src/guest-source.ts
index b810c95033..64f04db286 100644
--- a/packages/workflow/workflow-ptc/src/guest-source.ts
+++ b/packages/workflow/workflow-ptc/src/guest-source.ts
@@ -1,4 +1,4 @@
/** Generated by scripts/gen-workflow-guest.ts. Do not edit directly. */
/** Self-contained ESM guest transported into the mounted PTC runtime. */
-export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tlet parent;\n\tconst assign = (item) => {\n\t\tif (parent === void 0) {\n\t\t\tif (detach) root = item;\n\t\t} else if (parent.kind === \"array\") {\n\t\t\tif (parent.target !== void 0) parent.target[parent.key] = item;\n\t\t} else if (parent.target !== void 0) Object.defineProperty(parent.target, parent.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst frames = [];\n\tlet current = value;\n\tfor (;;) {\n\t\tif (current === null) assign(null);\n\t\telse if (typeof current === \"boolean\" || typeof current === \"string\") assign(current);\n\t\telse if (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(current);\n\t\t} else {\n\t\t\tif (typeof current !== \"object\") return void 0;\n\t\t\tif (ancestors.has(current)) return void 0;\n\t\t\tif (Array.isArray(current)) {\n\t\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\t\tconst length = current.length;\n\t\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\t\tconst target = detach ? [] : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tlet indices;\n\t\t\t\tif (!Number.isInteger(length)) {\n\t\t\t\t\tindices = [];\n\t\t\t\t\tfor (let index = length - 1; index >= 0; index--) indices.push(index);\n\t\t\t\t}\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tlength: indices?.length ?? length,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: 0,\n\t\t\t\t\tindices,\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\t\t\tconst keys = enumerableStringKeys(current);\n\t\t\t\tif (keys === void 0) return void 0;\n\t\t\t\tconst target = detach ? {} : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tkeys,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: \"\",\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t}\n\t\t\tancestors.add(current);\n\t\t}\n\t\tfor (;;) {\n\t\t\tconst frame = frames.at(-1);\n\t\t\tif (frame === void 0) return detach ? root : true;\n\t\t\tconst length = frame.kind === \"array\" ? frame.length : frame.keys.length;\n\t\t\tif (frame.index >= length) {\n\t\t\t\tancestors.delete(frame.source);\n\t\t\t\tframes.pop();\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst index = frame.index++;\n\t\t\tparent = frame;\n\t\t\tif (frame.kind === \"array\") {\n\t\t\t\tconst key = frame.indices === void 0 ? index : frame.indices[frame.indices.length - index - 1];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured index count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tif (!Object.prototype.hasOwnProperty.call(frame.source, key)) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t} else {\n\t\t\t\tconst key = frame.keys[index];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n"
+export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\n/**\n* Every failure code the built-in providers and the LLM runtime raise. An entry\n* marked `retryChoice` is offered as a choice for a provider retry policy's\n* `retryableCodes`; being a choice does not make a code retried by default.\n*/\nconst LLM_FAILURE_CODES = {\n\tABORTED: {},\n\tACCOUNT_QUOTA: {},\n\tACCOUNT_SIGN_IN_REQUIRED: {},\n\tACCOUNT_TOKEN_INVALID: {},\n\tAUTH: {},\n\tCLIENT_UPGRADE_REQUIRED: {},\n\tCONTEXT_WINDOW_EXCEEDED: {},\n\tDISCOVERY_FAILED: {},\n\tDISCOVERY_UNSUPPORTED: {},\n\tDUPLICATE_ADAPTER: {},\n\tDUPLICATE_DIRECTORY: {},\n\tDUPLICATE_DISCOVERY: {},\n\tEMPTY_RESPONSE: { retryChoice: true },\n\tFILES_API: {},\n\tIMAGE_OFFLOAD_REQUIRED: {},\n\tINVALID_ADAPTER: {},\n\tINVALID_CATALOG: {},\n\tINVALID_CONFIG: {},\n\tINVALID_CREDENTIAL: {},\n\tINVALID_DIRECTORY: {},\n\tINVALID_DISCOVERY: {},\n\tINVALID_MODEL_CONTEXT: {},\n\tINVALID_MODEL_INFO: {},\n\tINVALID_MODEL_MAX_TOKENS: {},\n\tINVALID_MODEL_REASONING: {},\n\tINVALID_PREPARED_CALL: {},\n\tINVALID_REPLAY_STATE: {},\n\tINVALID_REQUEST: {},\n\tINVALID_RESPONSE: {},\n\tMALFORMED_RESPONSE: { retryChoice: true },\n\tMISSING_CREDENTIAL: {},\n\tMODEL_SWITCH_COMPACTION_FAILED: {},\n\tMODEL_UNSUPPORTED: {},\n\tNO_ADAPTER: {},\n\tNO_CREDENTIAL_STORE: {},\n\tNO_DISCOVERY: {},\n\tPI_AI_ERROR: {},\n\tQUOTA: { retryChoice: true },\n\tRATE_LIMIT: { retryChoice: true },\n\tREGISTRATION_DISPOSED: {},\n\tREQUEST_EXTENSION: {},\n\tREQUEST_HEADERS: {},\n\tSERVER: { retryChoice: true },\n\tSTREAM_CLOSED: { retryChoice: true },\n\tTIMEOUT: { retryChoice: true },\n\tTRANSPORT: { retryChoice: true },\n\tUNKNOWN: {},\n\tUNKNOWN_MODEL: {},\n\tUNSTORABLE_PROVIDER_ID: {},\n\tUNSUPPORTED_CONTENT: {},\n\tUNSUPPORTED_OPTION: {},\n\tUNSUPPORTED_REASONING_EFFORT: {}\n};\nObject.freeze(Object.keys(LLM_FAILURE_CODES).filter((code) => \"retryChoice\" in LLM_FAILURE_CODES[code]));\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tlet parent;\n\tconst assign = (item) => {\n\t\tif (parent === void 0) {\n\t\t\tif (detach) root = item;\n\t\t} else if (parent.kind === \"array\") {\n\t\t\tif (parent.target !== void 0) parent.target[parent.key] = item;\n\t\t} else if (parent.target !== void 0) Object.defineProperty(parent.target, parent.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst frames = [];\n\tlet current = value;\n\tfor (;;) {\n\t\tif (current === null) assign(null);\n\t\telse if (typeof current === \"boolean\" || typeof current === \"string\") assign(current);\n\t\telse if (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(current);\n\t\t} else {\n\t\t\tif (typeof current !== \"object\") return void 0;\n\t\t\tif (ancestors.has(current)) return void 0;\n\t\t\tif (Array.isArray(current)) {\n\t\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\t\tconst length = current.length;\n\t\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\t\tconst target = detach ? [] : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tlet indices;\n\t\t\t\tif (!Number.isInteger(length)) {\n\t\t\t\t\tindices = [];\n\t\t\t\t\tfor (let index = length - 1; index >= 0; index--) indices.push(index);\n\t\t\t\t}\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tlength: indices?.length ?? length,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: 0,\n\t\t\t\t\tindices,\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\t\t\tconst keys = enumerableStringKeys(current);\n\t\t\t\tif (keys === void 0) return void 0;\n\t\t\t\tconst target = detach ? {} : void 0;\n\t\t\t\tif (target !== void 0) assign(target);\n\t\t\t\tframes.push({\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\tsource: current,\n\t\t\t\t\tkeys,\n\t\t\t\t\tindex: 0,\n\t\t\t\t\tkey: \"\",\n\t\t\t\t\ttarget\n\t\t\t\t});\n\t\t\t}\n\t\t\tancestors.add(current);\n\t\t}\n\t\tfor (;;) {\n\t\t\tconst frame = frames.at(-1);\n\t\t\tif (frame === void 0) return detach ? root : true;\n\t\t\tconst length = frame.kind === \"array\" ? frame.length : frame.keys.length;\n\t\t\tif (frame.index >= length) {\n\t\t\t\tancestors.delete(frame.source);\n\t\t\t\tframes.pop();\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst index = frame.index++;\n\t\t\tparent = frame;\n\t\t\tif (frame.kind === \"array\") {\n\t\t\t\tconst key = frame.indices === void 0 ? index : frame.indices[frame.indices.length - index - 1];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured index count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tif (!Object.prototype.hasOwnProperty.call(frame.source, key)) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t} else {\n\t\t\t\tconst key = frame.keys[index];\n\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\t\tif (key === void 0) return void 0;\n\t\t\t\tframe.key = key;\n\t\t\t\tcurrent = frame.source[key];\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n"
From 38c32cf258b0140e9c3adf48bf36155705c5ebae Mon Sep 17 00:00:00 2001
From: Turtle
Date: Tue, 3 Nov 2026 17:05:03 +0800
Subject: [PATCH 013/106] docs: regenerate event graph line references
---
docs/event-producer-consumer.md | 2 +-
docs/event-producer-consumer.zh.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index d88ab4e38d..964363a3b0 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -59,7 +59,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:76`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:432`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:438`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 986d33e3eb..423ce886ca 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -61,7 +61,7 @@
| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:76`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:432`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:438`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
From f5dea38671beb4054036ab73ed03963d108c0153 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Mon, 26 Oct 2026 20:15:50 +0800
Subject: [PATCH 014/106] feat(sandbox): add session-scoped writable directory
effects
---
...026-10-08-persistent-ptc-sandbox.i18n.yaml | 4 +-
.../2026-10-08-persistent-ptc-sandbox.md | 2 +-
.../2026-10-08-persistent-ptc-sandbox.zh.md | 2 +-
...ssion-writable-directory-effects.i18n.yaml | 19 +++++
...0-26-session-writable-directory-effects.md | 27 +++++++
...6-session-writable-directory-effects.zh.md | 27 +++++++
docs/subsystems/sandbox.md | 16 +++-
docs/subsystems/sandbox.zh.md | 16 +++-
.../extensions/tool-cordis/src/api-catalog.ts | 9 ++-
packages/fs/fs-sandbox/package.json | 1 +
.../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 28 ++++++-
.../ptc-runtime-node/README.i18n.yaml | 4 +-
.../ptc-runtime/ptc-runtime-node/README.md | 2 +-
.../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +-
.../ptc-runtime-node/src/execution.ts | 17 +---
.../tests/persistent-sandbox.spec.ts | 41 ++++++----
.../ptc-runtime-node/tests/persistent.spec.ts | 4 +-
.../sandbox/sandbox-policy/README.i18n.yaml | 3 +
packages/sandbox/sandbox-policy/README.md | 6 ++
packages/sandbox/sandbox-policy/README.zh.md | 6 ++
packages/sandbox/sandbox-policy/src/index.ts | 41 +++++++++-
.../sandbox-policy/tests/policy.spec.ts | 78 +++++++++++++++++++
.../tests/fixtures/session-write-grant.ts | 23 ++++++
pnpm-lock.yaml | 3 +
.../session-sandbox-root/cordis.snapshot.yml | 3 +-
.../session/session-sandbox-root/cordis.yml | 5 +-
.../session-sandbox-root/session.v4.jsonl | 5 +-
27 files changed, 343 insertions(+), 51 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
create mode 100644 .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
create mode 100644 packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml
index 262e03bb22..ff9a3301b2 100644
--- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.i18n.yaml
@@ -15,5 +15,5 @@
en: 10bebc7f982cf7f4
zh: 9247a2ba57921a11
/agent-note-retained-ptc-processes-follow-file-policy/consequences:
- en: b3ddf273158d3754
- zh: 916356f4074b3622
+ en: 1f94ca28e0cb7564
+ zh: d8a918c0c5dfc6d8
diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md
index 2c4235fcc3..21c3d59188 100644
--- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md
+++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.md
@@ -28,4 +28,4 @@ An approved one-time escalation omits the state owner and receives a fresh tempo
## Consequences
-Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before supplying a different policy without a Session mode change; the provider rejects inconsistent reuse. The sandbox inherits the existing backends’ file-effect coverage and platform limitations.
+Normal calls retain variables without changing file permissions. Changing sandbox mode discards variables. Escalated programs must be self-contained. Runtime consumers must release a retained owner before changing its mode, workspace root or Session identity; the provider rejects inconsistent reuse. [Directory-effect lifetimes](2026-10-26-session-writable-directory-effects.md) permit extra-directory changes while retained processes keep their launch grants. The sandbox inherits the existing backends’ file-effect coverage and platform limitations.
diff --git a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md
index d841702941..6403ed5ef1 100644
--- a/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-08-persistent-ptc-sandbox.zh.md
@@ -28,4 +28,4 @@ Status: implemented
## 影响
-普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。未发生 Session 模式变化时,运行时消费者在传入不同策略前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。沙箱继承现有后端的文件约束范围与平台限制。
+普通调用保留变量,同时维持原文件权限。切换沙箱模式会丢弃变量。提权程序必须包含执行所需的完整代码。运行时消费者在更改模式、工作区根目录或 Session 身份前必须释放保留状态的所有者;提供方拒绝策略不一致的复用。[目录 effect 生命周期](2026-10-26-session-writable-directory-effects.zh.md)允许额外目录发生变化,同时保留进程继续使用启动时的授权。沙箱继承现有后端的文件约束范围与平台限制。
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
new file mode 100644
index 0000000000..77a0e4417c
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-10-26-session-writable-directory-effects.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
+/agent-note-session-writable-directory-effects:
+ en: b5a98da65d44e212
+ zh: ba6842a9b4a890f7
+/agent-note-session-writable-directory-effects/problem:
+ en: 2a32db1f27371403
+ zh: 89cf04705ef66769
+/agent-note-session-writable-directory-effects/decision:
+ en: a6618e4ee649c198
+ zh: 5756dad5dd01dede
+/agent-note-session-writable-directory-effects/alternatives-considered:
+ en: dad77744dea17bde
+ zh: bb3838f9f9099944
+/agent-note-session-writable-directory-effects/consequences:
+ en: 7edcc1022b9f3ee5
+ zh: 61163e25d5a90f16
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
new file mode 100644
index 0000000000..1292e43178
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
@@ -0,0 +1,27 @@
+# Agent Note: Session writable-directory effects
+
+Status: implemented
+
+English | [中文](2026-10-26-session-writable-directory-effects.zh.md)
+
+## Problem
+
+Memory plugins need agent writes outside the workspace without granting every Session the same directory. Deployment-wide roots cannot express a plugin enabled for only one Session or a grant that ends when that plugin unloads.
+
+## Decision
+
+The sandbox-policy service owns effect-scoped directory registrations keyed by live Session identity. Configured grants remain shared; runtime grants apply only to their named Session. Each resolution captures the active union, and existing request-context logging records the directories advertised to the model. Resume and fork require new registrations rather than restoring authority from historical text.
+
+Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged.
+
+## Alternatives considered
+
+**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service.
+
+**Immediate process revocation.** Existing sandboxes apply grants at launch. Stopping and recreating processes would discard terminal or PTC state and require cleanup coordination beyond the accepted future-operation requirement.
+
+**Persist runtime grants as Session permissions.** Replaying such grants could authorize directories after the plugin that supplied them has been removed. The active plugin composition owns these grants.
+
+## Consequences
+
+Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocess confinement retains its existing unsupported-extra-roots error. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
new file mode 100644
index 0000000000..8c934b20ff
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
@@ -0,0 +1,27 @@
+# Agent Note: 会话可写目录 effect
+
+Status: implemented
+
+[English](2026-10-26-session-writable-directory-effects.md) | 中文
+
+## 问题
+
+记忆插件需要允许 agent 在工作区之外写入,同时不向所有 Session 授予相同目录。部署级根目录无法表达只为一个 Session 启用的插件,或随插件卸载而结束的授权。
+
+## 决策
+
+sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周期管理的目录注册。配置授权仍然共享;运行时授权只适用于指定 Session。每次解析捕获当前授权的并集,现有请求上下文日志记录向模型展示的目录。恢复与派生需要重新注册,而非从历史文本恢复权限。
+
+注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。
+
+## 考虑过的替代方案
+
+**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。
+
+**立即撤销进程权限。** 现有沙箱在启动时应用授权。停止并重新创建进程会丢失终端或 PTC 状态,并需要超出已接受的后续操作需求的清理协调。
+
+**将运行时授权持久化为 Session 权限。** 回放此类授权可能在提供授权的插件已被移除后继续授权目录。当前插件组合拥有这些授权。
+
+## 影响
+
+同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程隔离保留现有的不支持额外根目录错误。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md
index 1b8c843049..2fe2c319c1 100644
--- a/docs/subsystems/sandbox.md
+++ b/docs/subsystems/sandbox.md
@@ -208,12 +208,26 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo
* mode outranks the session's last `sandbox/mode` event, which outranks the
* deployment default. A session cwd is its workspace-write boundary; the
* configured root is the fallback for agentless calls and sessions without a
- * cwd.
+ * cwd. Configured extra roots and this Session's active registrations are
+ * snapshotted together; existing operations and processes keep their policy.
* @param request - optional session and approved mode override.
* @returns the fully resolved per-call mode and absolute workspace root.
*/
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
+/**
+ * Permit writes under one directory in this Session's future workspace-write
+ * operations. The calling context owns the effect; disposal removes only this
+ * registration. Other grants to the directory remain effective. Existing
+ * operations and persistent processes retain their resolved permissions.
+ * Grants are runtime-only and must be registered again after resume or fork.
+ * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
+ * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.
+ * @returns the exact Cordis effect disposer for this registration.
+ * @throws when the directory is relative or contains a NUL byte.
+ */
+registerWritableRoot(session: Session, directory: string): () => void
+
/**
* Read the session override without applying the deployment default.
* @param session - session whose log supplies the override.
diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md
index 8141d365c0..7e54a4a5fd 100644
--- a/docs/subsystems/sandbox.zh.md
+++ b/docs/subsystems/sandbox.zh.md
@@ -208,12 +208,26 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo
* mode outranks the session's last `sandbox/mode` event, which outranks the
* deployment default. A session cwd is its workspace-write boundary; the
* configured root is the fallback for agentless calls and sessions without a
- * cwd.
+ * cwd. Configured extra roots and this Session's active registrations are
+ * snapshotted together; existing operations and processes keep their policy.
* @param request - optional session and approved mode override.
* @returns the fully resolved per-call mode and absolute workspace root.
*/
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
+/**
+ * Permit writes under one directory in this Session's future workspace-write
+ * operations. The calling context owns the effect; disposal removes only this
+ * registration. Other grants to the directory remain effective. Existing
+ * operations and persistent processes retain their resolved permissions.
+ * Grants are runtime-only and must be registered again after resume or fork.
+ * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
+ * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.
+ * @returns the exact Cordis effect disposer for this registration.
+ * @throws when the directory is relative or contains a NUL byte.
+ */
+registerWritableRoot(session: Session, directory: string): () => void
+
/**
* Read the session override without applying the deployment default.
* @param session - session whose log supplies the override.
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 6d2208cf96..a11efdbd50 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -2579,10 +2579,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy',
- description: 'Resolve the complete policy for one capability call. An approved explicit mode outranks the session\'s last `sandbox/mode` event, which outranks the deployment default. A session cwd is its workspace-write boundary; the configured root is the fallback for agentless calls and sessions without a cwd.',
+ description: 'Resolve the complete policy for one capability call. An approved explicit mode outranks the session\'s last `sandbox/mode` event, which outranks the deployment default. A session cwd is its workspace-write boundary; the configured root is the fallback for agentless calls and sessions without a cwd. Configured extra roots and this Session\'s active registrations are snapshotted together; existing operations and processes keep their policy.',
parameters: [{ name: 'request', description: 'optional session and approved mode override.' }],
returns: 'the fully resolved per-call mode and absolute workspace root.',
},
+ {
+ signature: 'registerWritableRoot(session: Session, directory: string): () => void',
+ description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.',
+ parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.' }],
+ returns: 'the exact Cordis effect disposer for this registration.',
+ throws: ['when the directory is relative or contains a NUL byte.'],
+ },
{
signature: 'overrideOf(session: Session): SandboxMode | undefined',
description: 'Read the session override without applying the deployment default.',
diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json
index ee56e522ea..86820aa118 100644
--- a/packages/fs/fs-sandbox/package.json
+++ b/packages/fs/fs-sandbox/package.json
@@ -36,6 +36,7 @@
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
+ "@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-fs": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
index 382668591d..e0a00e2adb 100644
--- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
+++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
@@ -16,7 +16,8 @@ import { join, parse } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
-import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
@@ -91,6 +92,31 @@ describe('read-only', () => {
describe('workspace-write containment', () => {
beforeEach(() => boot('workspace-write'))
+ it('applies session grants to later writes and removes them when their plugin unloads', async ({ onTestFinished }) => {
+ onTestFinished(() => ctx.fiber.dispose())
+ const first = Session.create(SessionId('memory-first'))
+ const second = Session.create(SessionId('memory-second'))
+ const path = join(outside, 'memory.txt')
+ const write = async (session: Session, content: string) => fs.writeText(
+ await target(path), content, undefined, undefined, ctx.sandboxPolicy.resolve({ session }),
+ )
+ await expect(write(first, 'denied')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
+ const plugin = await ctx.plugin({
+ inject: ['sandboxPolicy'],
+ apply(scope: Context) { scope.sandboxPolicy.registerWritableRoot(first, outside) },
+ })
+ await write(first, 'memory')
+ expect(await readFile(path, 'utf8')).toBe('memory')
+ await expect(write(second, 'other session')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
+ setSandboxMode(first, 'read-only')
+ await expect(write(first, 'read-only')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
+ setSandboxMode(first, 'workspace-write')
+ await write(first, 'updated')
+ await plugin.dispose()
+ await expect(write(first, 'revoked')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
+ expect(await readFile(path, 'utf8')).toBe('updated')
+ })
+
it('rejects an extra directory replaced by a symlink to an ungranted sibling', async () => {
const extra = join(workspace, 'extra')
await mkdir(extra)
diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
index f906f1a694..1ae88f5606 100644
--- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
+++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 2a9fa3292bf07355
zh: db0b5684820915eb
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/in-memory-modes:
- en: 0204fbdcb55c9c0d
- zh: 8e6b6f3974993ca0
+ en: b1d9e8ce46f265ae
+ zh: d2294ca991f349df
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/deadlines-and-cancellation:
en: d383ec7aeab8afec
zh: 7c9c49e205fbbcd8
diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md
index 1ea61221bd..a2201ea7b2 100644
--- a/packages/ptc-runtime/ptc-runtime-node/README.md
+++ b/packages/ptc-runtime/ptc-runtime-node/README.md
@@ -95,7 +95,7 @@ Save the patch and pass its path to `dsh headless --patch ` in a compositi
In REPL mode, each live Agent keeps its own process and top-level variables, functions, and imported modules across `run_code` calls and turns. Use `await import(...)`, `console.log(...)`, and a final JSON expression; top-level `return` is not supported. Assignments to undeclared names create retained globals under Node REPL’s non-strict evaluation. `_` and `_error` are ordinary retained globals, without interactive REPL result setters. Node REPL declaration rules apply, including errors on redeclaring existing `let`, `const`, or `class` names; assign to a mutable binding or choose a new name. A leading `{ ... }` is a statement block and can contain top-level await and temporary declarations; parenthesize an object expression to return it. Retained functions and saved tool methods call the current evaluation's bindings. All tool calls must be awaited; callbacks from a completed evaluation cannot invoke tools in a later evaluation.
-Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Exceptions in the active evaluation settle once and preserve earlier mutations. In REPL mode, exceptions from callbacks belonging to a completed evaluation are not reported in a later result. In Script mode, uncaught callback exceptions can terminate the retained process; await asynchronous work before completing an evaluation. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Each evaluation resets the process working directory to its resolved cwd; program calls to `process.chdir()` do not persist across evaluations. Direct Node access follows the resolved file policy. A retained owner cannot change mode, workspace root, additional writable roots, or Session identity until its environment is released.
+Memory is best-effort and never saved to disk. Restart, Agent disposal, provider unload, protocol failure, output overflow, or process exit clears it. Missing variables raise normal reference errors; reacquire handles and recreate declarations. Exceptions in the active evaluation settle once and preserve earlier mutations. In REPL mode, exceptions from callbacks belonging to a completed evaluation are not reported in a later result. In Script mode, uncaught callback exceptions can terminate the retained process; await asynchronous work before completing an evaluation. The Session still records programs, tool dispatches and visible results; restoring a Session or forking an Agent starts with empty memory. Each evaluation resets the process working directory to its resolved cwd; program calls to `process.chdir()` do not persist across evaluations. Direct Node access follows the process launch policy. A retained owner cannot change mode, workspace root, or Session identity until its environment is released. Additional writable directories are fixed when the process starts: later grants and revocations apply only after release and recreation.
Service consumers pass an object as `stateOwner` to reuse its environment and await `release(owner)` when finished. Requests without an owner run in temporary processes. Their failures leave other owners' retained variables intact and do not report clearing them. Permission setters record the new mode synchronously. The runtime immediately invalidates affected processes and begins cancellation when the Session mode changes, including idle environments. The next program waits for their cleanup before starting; cleanup failure blocks that program and remains retryable. Permission-setting success does not mean old processes have finished stopping. A one-time approved escalation uses a separate environment and awaits its cleanup before returning; it cannot read the standing environment's variables or retain new declarations. Concurrent runs for one owner reject until execution and required cleanup complete; distinct owners may run concurrently. An idle environment has no expiration timer and keeps its process until release or failure. Raw stdout/stderr have no evaluation identity: bytes received during an evaluation share its output budget, even when written by an earlier child process; idle stdout is discarded and idle stderr retains only the bounded diagnostic tail. Await child-process completion before ending an evaluation. Process-exit diagnostics include the exit code and retained stderr tail; idle failures are written to the host logger. If process observation fails during cleanup, releasing the owner retries termination; new runs remain blocked until cleanup succeeds. Pipes that fail to drain after confirmed process exit produce a diagnostic but do not prevent releasing the environment.
diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md
index ee8f2e01e6..4a0cbd4bda 100644
--- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md
+++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md
@@ -95,7 +95,7 @@ profile 编辑器可从 `@deepseek-ai/dsh-ptc-runtime-node/configuration` 导入
在 REPL 模式下,每个存活 Agent 保留独立进程,跨 `run_code` 调用和轮次保留顶层变量、函数及导入模块。使用 `await import(...)`、`console.log(...)` 和末尾 JSON 表达式;不支持顶层 `return`。Node REPL 使用非严格模式,给未声明的名称赋值会创建保留的全局变量。`_` 和 `_error` 是普通的保留全局变量,不使用交互式 REPL 的结果设置器。声明遵循 Node REPL 规则,包括重复声明已有 `let`、`const` 或 `class` 名称时报错;应给可变绑定赋值或选择新名称。开头的 `{ ... }` 按语句块执行,可以包含顶层 await 和临时声明;需要返回对象表达式时,用圆括号包裹。保留的函数和工具方法引用调用当前执行的绑定。必须等待所有工具调用;已完成执行的回调不能在后续执行中调用工具。
-内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。当前执行中的异常只结算一次,并保留此前发生的修改。在 REPL 模式下,已完成执行的回调抛出的异常不会在后续结果中报告。在 Script 模式下,未捕获的回调异常可能终止保留进程;结束执行前应等待异步工作完成。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。每次执行都会将进程工作目录重置为本次解析的 cwd;程序中的 `process.chdir()` 不跨执行保留。直接 Node 访问遵循已解析的文件策略。保留状态的所有者必须先释放环境,才能更改权限模式、工作区根目录、额外可写根目录或 Session 身份。
+内存按尽力保留处理,不保存到磁盘。重启、Agent 销毁、提供方卸载、协议失败、输出超限或进程退出都会清除内存。缺失变量产生普通引用错误,模型需要重新获取句柄和创建声明。当前执行中的异常只结算一次,并保留此前发生的修改。在 REPL 模式下,已完成执行的回调抛出的异常不会在后续结果中报告。在 Script 模式下,未捕获的回调异常可能终止保留进程;结束执行前应等待异步工作完成。Session 仍记录程序、工具分派和可见结果;恢复 Session 或派生 Agent 时从空内存开始。每次执行都会将进程工作目录重置为本次解析的 cwd;程序中的 `process.chdir()` 不跨执行保留。直接 Node 访问遵循进程启动时的文件策略。保留状态的所有者必须先释放环境,才能更改权限模式、工作区根目录或 Session 身份。额外可写目录在进程启动时固定:之后的授权和撤销仅在释放并重新创建环境后生效。
服务消费者通过 `stateOwner` 对象复用环境,并在结束时等待 `release(owner)`。没有所有者的请求在临时进程中执行。此类执行失败不影响其他所有者保留的变量,也不报告清空了这些变量。权限设置同步记录新模式。Session 模式变化时,运行时立即禁止复用受影响的进程并开始取消,包括空闲环境。下一次程序执行前等待这些进程清理;清理失败会阻止本次程序启动,并允许重试。权限设置成功时,旧进程可能仍在停止过程中。单次获准的提权使用独立环境,返回前等待清理;该次执行无法访问原环境的变量,也不会保留新声明。执行及必要清理完成之前,同一所有者的并发调用会被拒绝;不同所有者可以并发。空闲环境没有过期计时器,进程保留到释放或失败为止。原始 stdout/stderr 不携带执行标识:执行期间收到的字节计入本次输出预算,包括较早启动的子进程此时写出的内容;空闲期间丢弃 stdout,只保留限额内的 stderr 诊断尾部。结束本次执行前,应等待子进程完成。进程退出诊断包含退出码和保留的 stderr 尾部;空闲期间的失败写入宿主日志。清理期间无法观测进程范围时,释放所有者会重试终止;清理成功前拒绝新执行。确认进程退出后仍未排空的管道会产生诊断,但不阻止释放环境。
diff --git a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts
index 4d9422ab5e..5028a9f178 100644
--- a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts
@@ -92,10 +92,10 @@ class NodeExecution {
worker?.assertIdle()
if (worker && !worker.closed && (worker.policy.mode !== spec.sandboxPolicy.mode
|| worker.policy.workspaceRoot !== spec.sandboxPolicy.workspaceRoot
- || worker.policy.sessionId !== spec.sandboxPolicy.sessionId
- || !sameAdditionalRoots(worker.policy.additionalWritableRoots, spec.sandboxPolicy.additionalWritableRoots))) {
+ || worker.policy.sessionId !== spec.sandboxPolicy.sessionId)) {
throw new Error('persistent PTC: release the retained environment before changing its sandbox policy')
}
+ // Retained processes keep their launch-time directory grants until release.
if (!worker || worker.closed) {
if (worker) this.workers.delete(worker)
// Direct callers can own mutable policy arrays; the retained process owns its grant snapshot.
@@ -127,19 +127,6 @@ class NodeExecution {
}
}
-/**
- * Compare requested extra grants without resolving paths in another execution world.
- * @param left - one policy's additional roots.
- * @param right - the other policy's additional roots.
- * @returns whether both contain the same paths, ignoring order and duplicates.
- */
-function sameAdditionalRoots(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean {
- const effective = (roots: readonly string[] | undefined): string[] => [...new Set(roots ?? [])].sort()
- const a = effective(left)
- const b = effective(right)
- return a.length === b.length && a.every((root, index) => root === b[index])
-}
-
const STRIP_PREFIX = 'async function __dsh_program__() {\n'
const STRIP_SUFFIX = '\n}'
const RETAINED_STATE_INSTRUCTIONS = 'Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation.'
diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
index a1a2dd1881..d364fb88f0 100644
--- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
@@ -8,14 +8,15 @@ import { Context } from '@deepseek-ai/cordis'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { expect, it, onTestFinished, vi } from 'vitest'
import { mountRuntime } from './setup.ts'
+import type { Config } from '../src/index.ts'
-async function setup(mode: 'read-only' | 'workspace-write' = 'read-only', executionMode: 'per-call' | 'persistent-in-memory' = 'persistent-in-memory') {
+async function setup(mode: 'read-only' | 'workspace-write' = 'read-only', executionMode: NonNullable = 'persistent-in-memory') {
const root = await mkdtemp(join(homedir(), '.dsh-persistent-sandbox-'))
const cwd = join(root, 'workspace')
await mkdir(cwd)
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
- const { runtime } = await mountRuntime(ctx, { executionMode }, { mode, workspaceRoot: cwd })
+ const { runtime } = await mountRuntime(ctx, { executionMode, scriptPersistence: executionMode === 'vm-script' }, { mode, workspaceRoot: cwd })
const owner = {}
const run = (program: string) => runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))
return { root, cwd, ctx, runtime, owner, run }
@@ -50,25 +51,33 @@ it('refuses reuse under another policy until the owner has been released', async
})
// Windows ACL rejects additional roots; its refusal is covered by sandbox-local's runner tests.
-it.skipIf(process.platform === 'win32')('requires release before changing a retained environment\'s additional writable roots', async () => {
- const { root, cwd, runtime, owner, run } = await setup('workspace-write')
+it.skipIf(process.platform === 'win32').each(['persistent-in-memory', 'vm-script'] as const)('keeps %s directory grants until the retained process is released', async (executionMode) => {
+ const { root, cwd, runtime, owner } = await setup('workspace-write', executionMode)
const extra = join(root, 'extra')
await mkdir(extra)
- expect((await run('var retained = 7; retained')).value).toBe(7)
- const spec = runtime.resolve({
- program: 'typeof retained',
+ const target = join(extra, 'memory.txt')
+ const roots = [extra]
+ const policy = { mode: 'workspace-write' as const, workspaceRoot: cwd, additionalWritableRoots: roots }
+ const write = (content: string, additionalWritableRoots: readonly string[] = []) => runtime.run(runtime.resolve({
+ program: `await (await import("node:fs/promises")).writeFile(${JSON.stringify(target)}, ${JSON.stringify(content)})`,
bindings: [],
stateOwner: owner,
- sandboxPolicy: { mode: 'workspace-write', workspaceRoot: cwd, additionalWritableRoots: [extra] },
- })
- await expect(runtime.run(spec)).rejects.toThrow('release the retained environment')
- expect((await run('retained')).value).toBe(7)
+ sandboxPolicy: { ...policy, additionalWritableRoots },
+ }))
+
+ expect((await write('initial', roots)).error).toBeUndefined()
+ roots[0] = cwd
+ expect((await write('retained')).error).toBeUndefined()
+ expect(await readFile(target, 'utf8')).toBe('retained')
+
await runtime.release(owner)
- expect((await runtime.run(spec)).value).toBe('undefined')
- const sameRoots = runtime.resolve({ ...spec, program: 'var second = 8; second', sandboxPolicy: { mode: 'workspace-write', workspaceRoot: cwd, additionalWritableRoots: [extra, extra] } })
- expect((await runtime.run(sameRoots)).value).toBe(8)
- const changed = runtime.resolve({ ...spec, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: cwd, additionalWritableRoots: [cwd] } })
- await expect(runtime.run(changed)).rejects.toThrow('release the retained environment')
+ expect((await write('denied')).sandbox).toMatchObject({ mode: 'workspace-write', denied: true })
+ expect((await write('still denied', [extra])).sandbox).toMatchObject({ mode: 'workspace-write', denied: true })
+ expect(await readFile(target, 'utf8')).toBe('retained')
+
+ await runtime.release(owner)
+ expect((await write('renewed', [extra])).error).toBeUndefined()
+ expect(await readFile(target, 'utf8')).toBe('renewed')
})
it('fails closed when confinement is unavailable without spawning Node', async () => {
diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts
index 0ec2444a46..8edc48c196 100644
--- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts
@@ -39,14 +39,14 @@ describe.each([
expect((await run('typeof retained')).value).toBe('undefined')
} finally { await runtime.release(owner); await pending }
})
- it('keeps a snapshot of caller-owned root arrays and rejects changed grants', async () => {
+ it('retains variables across additional-root changes until release', async () => {
const { run, runtime, owner, cwd } = await setup()
const roots = [cwd]
const policy = { mode: 'danger-full-access' as const, workspaceRoot: cwd, additionalWritableRoots: roots }
expect((await run('var retained = 7; retained', { sandboxPolicy: policy })).value).toBe(7)
expect((await run('retained', { sandboxPolicy: { ...policy, additionalWritableRoots: [cwd, cwd] } })).value).toBe(7)
roots[0] = tmpdir()
- await expect(run('retained', { sandboxPolicy: policy })).rejects.toThrow('release the retained environment')
+ expect((await run('retained', { sandboxPolicy: policy })).value).toBe(7)
await runtime.release(owner)
expect((await run('typeof retained', { sandboxPolicy: policy })).value).toBe('undefined')
})
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index 4e91831598..65121d087e 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -23,6 +23,9 @@
/deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration:
en: 069de80f17900193
zh: 2833f134c8dcb2e4
+/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
+ en: c87ff1fab6cc3a2e
+ zh: 4970ba31546a0e25
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
en: 957d72691e470e5c
zh: c22a3a750af0e4a3
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index c2ff904ac7..38c8ef41ad 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -52,6 +52,12 @@ For a linked Git worktree, the caller can grant its shared Git directory with `a
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-sandbox-policy) is the exhaustive source for every accepted field and its JSDoc.
+### Registering a session's writable directory
+
+A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Prepare the directory on the execution host before granting it; the same path checks as configured roots apply.
+
+Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only, and the [Windows subprocess restriction](../sandbox-local/README.md#known-limitations-and-deferred-work) still applies.
+
### Switching a session's mode
A session's mode can be switched at runtime through a UI policy control or an explicit switch; the switch is recorded in the session log and takes effect on the session's next confined call. The switch survives restart through replay, and each session keeps its own mode — two sessions never see each other's state. A switched session keeps its immutable workspace cwd as the writable boundary.
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index f097626989..1369bf3f0a 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -52,6 +52,12 @@ kind: "package-reference"
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-sandbox-policy)是每个受支持字段及其 JSDoc 的穷尽式真源。
+### 注册会话的可写目录
+
+插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。授权前先在执行主机上准备好目录;路径检查与配置根目录相同。
+
+注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session 与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读,[Windows 子进程限制](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)仍然适用。
+
### 切换会话模式
会话的模式可以在运行时通过 UI 策略控件或显式切换来更改;切换记录在会话日志中,并在该会话的下一次受限调用时生效。切换通过回放跨重启保留,每个会话保持自己的模式——两个会话绝不会看到彼此状态。切换后的会话继续以不可变的工作区 cwd 作为写入边界。
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index 36fdc1f2e8..edc1110601 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -149,6 +149,8 @@ export class SandboxPolicyService extends Service {
readonly workspaceRoot: string
/** The frozen extra writable directories shared by every resolved policy, or `undefined` for none. */
readonly additionalWritableRoots: readonly string[] | undefined
+ /** Independently owned grants for each live Session; never restored from its log. */
+ private readonly writableRootRegistrations = new WeakMap>()
private sandboxModeProjectionHandle!: ProjectionHandle
constructor(ctx: Context, config: Config) {
@@ -190,20 +192,55 @@ export class SandboxPolicyService extends Service {
* mode outranks the session's last `sandbox/mode` event, which outranks the
* deployment default. A session cwd is its workspace-write boundary; the
* configured root is the fallback for agentless calls and sessions without a
- * cwd.
+ * cwd. Configured extra roots and this Session's active registrations are
+ * snapshotted together; existing operations and processes keep their policy.
* @param request - optional session and approved mode override.
* @returns the fully resolved per-call mode and absolute workspace root.
*/
resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {
const { session } = request
+ const registrations = session === undefined ? undefined : this.writableRootRegistrations.get(session)
+ const additionalWritableRoots = registrations === undefined
+ ? this.additionalWritableRoots
+ : resolveAdditionalWritableRoots([...(this.additionalWritableRoots ?? []), ...registrations.values()])
return {
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
- ...this.additionalWritableRoots === undefined ? {} : { additionalWritableRoots: this.additionalWritableRoots },
+ ...additionalWritableRoots === undefined ? {} : { additionalWritableRoots },
...session === undefined ? {} : { sessionId: session.id },
}
}
+ /**
+ * Permit writes under one directory in this Session's future workspace-write
+ * operations. The calling context owns the effect; disposal removes only this
+ * registration. Other grants to the directory remain effective. Existing
+ * operations and persistent processes retain their resolved permissions.
+ * Grants are runtime-only and must be registered again after resume or fork.
+ * @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
+ * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.
+ * @returns the exact Cordis effect disposer for this registration.
+ * @throws when the directory is relative or contains a NUL byte.
+ */
+ registerWritableRoot(session: Session, directory: string): () => void {
+ resolveAdditionalWritableRoots([directory])
+ const dispose = this.ctx.effect(() => {
+ let registrations = this.writableRootRegistrations.get(session)
+ if (registrations === undefined) {
+ registrations = new Map()
+ this.writableRootRegistrations.set(session, registrations)
+ }
+ const key = Symbol()
+ registrations.set(key, directory)
+ return () => {
+ registrations.delete(key)
+ if (registrations.size === 0) this.writableRootRegistrations.delete(session)
+ }
+ }, 'sandboxPolicy.registerWritableRoot()')
+ // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup preserves the exact Cordis effect disposer
+ return dispose
+ }
+
/**
* Read the session override without applying the deployment default.
* @param session - session whose log supplies the override.
diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
index 15878013fa..16b6b2edd0 100644
--- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts
+++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
@@ -182,6 +182,84 @@ describe('SandboxPolicyService', () => {
})
})
+describe('session writable-root registrations', () => {
+ it('isolates live Sessions, preserves configured roots, and detaches resolved policies', async ({ onTestFinished }) => {
+ const ctx = await mounted({ mode: 'workspace-write', additionalWritableRoots: ['/shared'] })
+ onTestFinished(() => ctx.fiber.dispose())
+ const first = session('first', '/workspace')
+ const second = session('second', '/workspace')
+ const before = ctx.sandboxPolicy.resolve({ session: first })
+ const remove = ctx.sandboxPolicy.registerWritableRoot(first, '/memory')
+ const granted = ctx.sandboxPolicy.resolve({ session: first })
+
+ expect(granted.additionalWritableRoots).toEqual(['/shared', '/memory'])
+ expect(Object.isFrozen(granted.additionalWritableRoots)).toBe(true)
+ expect(before.additionalWritableRoots).toEqual(['/shared'])
+ expect(ctx.sandboxPolicy.resolve({ session: second }).additionalWritableRoots).toEqual(['/shared'])
+ expect(ctx.sandboxPolicy.resolve().additionalWritableRoots).toEqual(['/shared'])
+ const resumed = Session.create(first.id, first.snapshotEvents(), first.header)
+ expect(ctx.sandboxPolicy.resolve({ session: resumed }).additionalWritableRoots).toEqual(['/shared'])
+
+ remove()
+ expect(ctx.sandboxPolicy.resolve({ session: first }).additionalWritableRoots).toEqual(['/shared'])
+ expect(granted.additionalWritableRoots).toEqual(['/shared', '/memory'])
+ })
+
+ it('keeps duplicate grants independently owned across plugin unload and explicit disposal', async ({ onTestFinished }) => {
+ const ctx = await mounted({ mode: 'workspace-write', additionalWritableRoots: ['/shared'] })
+ onTestFinished(() => ctx.fiber.dispose())
+ const active = session('duplicates')
+ const fiber = await ctx.plugin({
+ inject: ['sandboxPolicy'],
+ apply(scope: Context) {
+ scope.sandboxPolicy.registerWritableRoot(active, '/memory')
+ scope.sandboxPolicy.registerWritableRoot(active, '/plugin-only')
+ scope.sandboxPolicy.registerWritableRoot(active, '/shared')
+ },
+ })
+ const remove = ctx.sandboxPolicy.registerWritableRoot(active, '/memory')
+ expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared', '/memory', '/plugin-only'])
+ await fiber.dispose()
+ expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared', '/memory'])
+ remove()
+ const replacement = ctx.sandboxPolicy.registerWritableRoot(active, '/replacement')
+ remove()
+ expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared', '/replacement'])
+ replacement()
+ expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toEqual(['/shared'])
+ })
+
+ it.each(['relative/memory', '/memory\0invalid'])('rejects an invalid directory without installing a grant: %j', async (directory) => {
+ const ctx = await mounted()
+ try {
+ const active = session('invalid')
+ expect(() => ctx.sandboxPolicy.registerWritableRoot(active, directory)).toThrow('absolute execution-world paths')
+ expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toBeUndefined()
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('updates request context without changing read-only policy or the stable prompt', async ({ onTestFinished }) => {
+ const ctx = await mounted({ mode: 'workspace-write' })
+ onTestFinished(() => ctx.fiber.dispose())
+ await ctx.plugin(SystemPrompt)
+ const active = session('context', '/workspace')
+ const before = await ctx.systemPrompt.assemble({ agent: agentFor(active) })
+ const remove = ctx.sandboxPolicy.registerWritableRoot(active, '/memory')
+ const after = await ctx.systemPrompt.assemble({ agent: agentFor(active) })
+ expect(renderPrompt(after)).toBe(renderPrompt(before))
+ expect(renderContextSnapshot(after)).toContain('Additional writable directories: ["/memory"]')
+ setSandboxMode(active, 'read-only')
+ expect(ctx.sandboxPolicy.resolve({ session: active }).mode).toBe('read-only')
+ expect(await policyContext(ctx, active)).not.toContain('/memory')
+ setSandboxMode(active, 'workspace-write')
+ remove()
+ expect(ctx.sandboxPolicy.resolve({ session: active }).additionalWritableRoots).toBeUndefined()
+ expect(await policyContext(ctx, active)).not.toContain('/memory')
+ })
+})
+
describe('sandbox:policy request context', () => {
async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string; additionalWritableRoots?: string[] } = {}): Promise {
const ctx = new Context()
diff --git a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
new file mode 100644
index 0000000000..7e2605cab6
--- /dev/null
+++ b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
@@ -0,0 +1,23 @@
+/** Session-owned write-grant changes observed through the real request context. */
+import { join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
+
+/** Snapshot fixture plugin name. */
+export const name = 'session-write-grant'
+/** The shared policy owns effect-scoped directory registrations. */
+export const inject = ['sandboxPolicy']
+
+/**
+ * Grant one directory until the first tool result, before the next request.
+ * @param ctx - fixture lifetime and policy service.
+ */
+export function apply(ctx: Context): void {
+ ctx.on('agent/created', ({ agent }) => {
+ const dispose = ctx.sandboxPolicy.registerWritableRoot(agent.session, join(process.cwd(), 'extra'))
+ ctx.on('session/event', (session, event) => {
+ if (session === agent.session && event.type === 'tool/result') dispose()
+ })
+ })
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a27cb2460f..fe551d41ae 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12044,6 +12044,9 @@ importers:
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:*
version: link:../../sandbox/sandbox-policy
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:*
+ version: link:../../core/session
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
diff --git a/snapshots/session/session-sandbox-root/cordis.snapshot.yml b/snapshots/session/session-sandbox-root/cordis.snapshot.yml
index 2f1112860a..8477136f86 100644
--- a/snapshots/session/session-sandbox-root/cordis.snapshot.yml
+++ b/snapshots/session/session-sandbox-root/cordis.snapshot.yml
@@ -45,7 +45,6 @@
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: /tmp
- additionalWritableRoots: !!js "[process.cwd() + '/extra']"
- id: headless-runner
inject: [headlessStartup]
@@ -56,6 +55,8 @@
permissionMode: workspace-write
- insert:
+ - id: session-write-grant
+ name: '../../../packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts'
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
diff --git a/snapshots/session/session-sandbox-root/cordis.yml b/snapshots/session/session-sandbox-root/cordis.yml
index 4c1988708e..f14a1c1ff8 100644
--- a/snapshots/session/session-sandbox-root/cordis.yml
+++ b/snapshots/session/session-sandbox-root/cordis.yml
@@ -7,7 +7,6 @@
config:
mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')"
workspaceRoot: /tmp
- additionalWritableRoots: !!js "[process.cwd() + '/extra']"
- id: headless-runner
inject: [headlessStartup]
@@ -16,3 +15,7 @@
sessionId: !!js ctx.headlessStartup.sessionId
json: !!js ctx.headlessStartup.json
permissionMode: workspace-write
+
+- insert:
+ - id: session-write-grant
+ name: '../../../packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts'
diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl
index f14da0e5f7..8290342b9e 100644
--- a/snapshots/session/session-sandbox-root/session.v4.jsonl
+++ b/snapshots/session/session-sandbox-root/session.v4.jsonl
@@ -15,11 +15,12 @@
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Use the write tool (NOT","messageSeqs":[11],"source":{"kind":"fallback"}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1792249808887,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792249808887,"index":0,"dt":[],"id":"call_session_root","name":"write","args":["{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"]},{"type":"chunk","time":1792249808887,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}},{"type":"chunk","time":1792249808887,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1792249808887,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1793015794532,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793015794532,"index":0,"dt":[],"id":"call_session_root","name":"write","args":["{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"]},{"type":"chunk","time":1793015794532,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}},{"type":"chunk","time":1793015794532,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1793015794532,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_session_root","name":"write","arguments":"{\"file_path\":\"session-root.txt\",\"content\":\"session root\"}"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_session_root"},"toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt \nfile \n\nCreated file\n "}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","path":"{{cwd}}/session-root.txt","diffs":[]}},"sourceEventSeqs":[17],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
-{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1792249809020,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792249809020,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1792249809020,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792249809020,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1792249809020,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1793015794554,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793015794554,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793015794554,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
From e032300b4e5453fc33e74d4b9fdcd1c5d96dedcf Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Mon, 26 Oct 2026 21:08:49 +0800
Subject: [PATCH 015/106] fix(sandbox): use current grants after failed PTC
startup
---
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
docs/subsystems/sandbox.i18n.yaml | 4 +-
docs/subsystems/sandbox.md | 4 +-
docs/subsystems/sandbox.zh.md | 4 +-
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../ptc-runtime-node/README.i18n.yaml | 8 ++--
.../ptc-runtime/ptc-runtime-node/README.md | 4 +-
.../ptc-runtime/ptc-runtime-node/README.zh.md | 4 +-
.../ptc-runtime-node/src/execution.ts | 7 +++-
.../ptc-runtime-node/src/worker.ts | 3 +-
.../tests/persistent-sandbox.spec.ts | 37 +++++++++++++++++++
.../sandbox/sandbox-policy/README.i18n.yaml | 8 ++--
packages/sandbox/sandbox-policy/README.md | 4 +-
packages/sandbox/sandbox-policy/README.zh.md | 4 +-
packages/sandbox/sandbox-policy/src/index.ts | 11 +++---
.../sandbox-policy/tests/policy.spec.ts | 2 +-
.../ptc-persistent/tool-schemas.expected.json | 2 +-
.../tool-schemas.expected.json | 2 +-
19 files changed, 78 insertions(+), 36 deletions(-)
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 541f35bf98..2d91981949 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2962,7 +2962,7 @@ export interface Config {
- `inject`: `sessionProjections`
- `refs`: [`SandboxMode`](subsystems/sandbox.md)
-- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:94`](../packages/sandbox/sandbox-policy/src/index.ts)
+- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:95`](../packages/sandbox/sandbox-policy/src/index.ts)
```ts config-catalog
/**
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index ddb66d4024..b586ca6aa2 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -2964,7 +2964,7 @@ export interface Config {
- `inject`: `sessionProjections`
- `refs`: [`SandboxMode`](subsystems/sandbox.zh.md)
-- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:94`](../packages/sandbox/sandbox-policy/src/index.ts)
+- `source`: [`packages/sandbox/sandbox-policy/src/index.ts:95`](../packages/sandbox/sandbox-policy/src/index.ts)
```ts config-catalog
/**
diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml
index 7f908ead84..05366c3c58 100644
--- a/docs/subsystems/sandbox.i18n.yaml
+++ b/docs/subsystems/sandbox.i18n.yaml
@@ -9,8 +9,8 @@
en: 6777e1b1270ec3e1
zh: 22533fb3304c1dd8
/process-sandbox/per-call-policy:
- en: fac6f5d32327dfb6
- zh: 1b8e4a1af48f33e8
+ en: 53850780ce70e3d8
+ zh: 60769c319d778e0e
/process-sandbox/wrapped-argv-and-classification-dialects:
en: 2ba8930762e92e40
zh: 220f64da1a9e5d57
diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md
index 2fe2c319c1..940bbfbf43 100644
--- a/docs/subsystems/sandbox.md
+++ b/docs/subsystems/sandbox.md
@@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial'
## Per-call policy
-The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration supplies the agentless fallback and explicit additional write grants. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs.
+The complete execution policy is resolved and carried per capability call. It includes `danger-full-access` so a consumer can resolve policy once before deciding whether to bypass confinement. Normal tool calls derive `workspaceRoot` from the calling session's immutable cwd; deployment configuration supplies the agentless fallback and shared additional write grants. Live Session registrations add effect-scoped directories to subsequent resolutions. The resolver preserves absolute execution-world spelling. Enforcing providers canonicalize the root where the files exist, so a cwd containing `symlink/..` identifies the directory where the paired subprocess provider actually runs.
```ts type-equiv
/**
@@ -200,7 +200,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox
### `ctx.sandboxPolicy` — `SandboxPolicyService`
-The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
+The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md
index 7e54a4a5fd..39cf22cf1c 100644
--- a/docs/subsystems/sandbox.zh.md
+++ b/docs/subsystems/sandbox.zh.md
@@ -40,7 +40,7 @@ type SandboxEnforcement = 'full' | 'partial'
## 逐调用策略
-完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置提供无 agent 调用的回退值和显式额外写入授权。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。
+完整执行策略会按每次能力调用解析并携带。它包括 `danger-full-access`,因此消费方可以只解析一次策略,再决定是否绕过约束。普通工具调用从调用会话的不可变 cwd 派生 `workspaceRoot`;部署配置提供无 agent 调用的回退值和共享的额外写入授权。存活 Session 的注册为后续解析添加 effect 生命周期内的目录授权。解析器保留执行环境中的绝对路径写法。执行限制的提供方在文件实际存在的位置规范化根目录,因此包含 `symlink/..` 的 cwd 会标识配套子进程提供方实际运行的目录。
```ts type-equiv
/**
@@ -200,7 +200,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox
### `ctx.sandboxPolicy` — `SandboxPolicyService`
-The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
+The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability.
```ts cordis-catalog
/**
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index a11efdbd50..b8c6b39cb9 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -2560,7 +2560,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sandboxPolicy',
summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).',
- description: 'The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session\'s mode log and immutable cwd travel together to every enforcing capability.',
+ description: 'The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, session-scoped directory grants, and request-time policy section. Tool layers call resolve for each execution so a session\'s mode log and immutable cwd travel together to every enforcing capability.',
methods: [
{
signature: 'readonly defaultMode: SandboxMode',
diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
index 1ae88f5606..2c4d3e2909 100644
--- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
+++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml
@@ -9,8 +9,8 @@
en: 7e1a8657a212c70b
zh: afb7a8c6a3afd718
/deepseek-ai-dsh-ptc-runtime-node/summary:
- en: fd81f3e969a3d8cc
- zh: a8211ce7c308dfd2
+ en: 7f80d74a7f87eb05
+ zh: 6aa31b97ac920ca6
/deepseek-ai-dsh-ptc-runtime-node/table-of-contents:
en: d152484eb41ac6b4
zh: 725b47765ee8f30b
@@ -24,8 +24,8 @@
en: 2a9fa3292bf07355
zh: db0b5684820915eb
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/in-memory-modes:
- en: b1d9e8ce46f265ae
- zh: d2294ca991f349df
+ en: c82e89a3221bfa3f
+ zh: 5b37a48d50291a06
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/deadlines-and-cancellation:
en: d383ec7aeab8afec
zh: 7c9c49e205fbbcd8
diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md
index a2201ea7b2..745df68fa8 100644
--- a/packages/ptc-runtime/ptc-runtime-node/README.md
+++ b/packages/ptc-runtime/ptc-runtime-node/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-Execute model-written TypeScript with host-provided async functions. The default `per-call` mode runs async function bodies in fresh Node processes; `vm-script` supports top-level await and optionally retains a context per Agent with `scriptPersistence`. Opt-in `persistent-in-memory` retains a REPL per Agent. All modes apply the selected file sandbox policy and return captured logs, lossless JSON, or structured failures. Elapsed deadlines, output bounds and a V8 heap limit constrain execution. Unacknowledged stops and one-shot completion clean up the managed process range; unavailable confinement fails execution.
+Execute model-written TypeScript with host-provided async functions. The default `per-call` mode runs async function bodies in fresh Node processes; `vm-script` supports top-level await and optionally retains a context per Agent with `scriptPersistence`. Opt-in `persistent-in-memory` retains a REPL per Agent. All modes apply the file sandbox policy at process launch and return captured logs, lossless JSON, or structured failures. Elapsed deadlines, output bounds and a V8 heap limit constrain execution. Unacknowledged stops and one-shot completion clean up the managed process range; unavailable confinement fails execution.
## Table of Contents
@@ -83,7 +83,7 @@ Set `executionMode: vm-script` to evaluate erasable TypeScript through `node:vm`
### In-memory modes
-The `persistent-in-memory` mode and Script mode with `scriptPersistence: true` retain state per owner. To select the REPL evaluator, set `executionMode: persistent-in-memory` on the existing `@deepseek-ai/dsh-ptc-runtime-node` plugin. All modes require `fs`, `subprocess`, `sandbox` and `sandboxPolicy` and apply the selected file sandbox policy. Enable it deliberately with a profile patch:
+The `persistent-in-memory` mode and Script mode with `scriptPersistence: true` retain state per owner. To select the REPL evaluator, set `executionMode: persistent-in-memory` on the existing `@deepseek-ai/dsh-ptc-runtime-node` plugin. All modes require `fs`, `subprocess`, `sandbox` and `sandboxPolicy` and apply the file sandbox policy at process launch. Enable it deliberately with a profile patch:
```yaml
- id: ptc-runtime
diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md
index 4a0cbd4bda..ff793aa587 100644
--- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md
+++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-执行模型编写的 TypeScript,并提供宿主异步函数。默认的 `per-call` 模式在全新 Node 进程中运行异步函数体;`vm-script` 支持顶层 await,并可通过 `scriptPersistence` 为每个 Agent 保留 context。显式启用的 `persistent-in-memory` 为每个 Agent 保留 REPL。所有模式都应用所选文件沙箱策略,返回捕获日志、无损 JSON 或结构化失败。执行时限、输出上限和 V8 堆上限约束运行。未确认的停止请求与单次执行完成后清理受管进程范围;约束不可用时执行失败。
+执行模型编写的 TypeScript,并提供宿主异步函数。默认的 `per-call` 模式在全新 Node 进程中运行异步函数体;`vm-script` 支持顶层 await,并可通过 `scriptPersistence` 为每个 Agent 保留 context。显式启用的 `persistent-in-memory` 为每个 Agent 保留 REPL。所有模式都在进程启动时应用文件沙箱策略,返回捕获日志、无损 JSON 或结构化失败。执行时限、输出上限和 V8 堆上限约束运行。未确认的停止请求与单次执行完成后清理受管进程范围;约束不可用时执行失败。
## 目录
@@ -83,7 +83,7 @@ profile 编辑器可从 `@deepseek-ai/dsh-ptc-runtime-node/configuration` 导入
### 内存保留模式
-`persistent-in-memory` 模式和启用 `scriptPersistence: true` 的 Script 模式都按所有者保留状态。要选择 REPL 求值器,在现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件上设置 `executionMode: persistent-in-memory`。所有模式都需要 `fs`、`subprocess`、`sandbox` 和 `sandboxPolicy`,并应用所选文件沙箱策略。通过配置档补丁显式启用:
+`persistent-in-memory` 模式和启用 `scriptPersistence: true` 的 Script 模式都按所有者保留状态。要选择 REPL 求值器,在现有 `@deepseek-ai/dsh-ptc-runtime-node` 插件上设置 `executionMode: persistent-in-memory`。所有模式都需要 `fs`、`subprocess`、`sandbox` 和 `sandboxPolicy`,并在进程启动时应用文件沙箱策略。通过配置档补丁显式启用:
```yaml
- id: ptc-runtime
diff --git a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts
index 5028a9f178..1ae7fde1ab 100644
--- a/packages/ptc-runtime/ptc-runtime-node/src/execution.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/src/execution.ts
@@ -145,13 +145,16 @@ function resolveMode(config: ResolvedConfig): ExecutionMode {
case 'persistent-in-memory': return {
format: 'repl',
retainsState: true,
- executionInstructions: 'The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty. ' + RETAINED_STATE_INSTRUCTIONS,
+ executionInstructions: 'The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows the environment\'s launch-time sandbox policy; process.env starts empty. ' + RETAINED_STATE_INSTRUCTIONS,
stripTypes: stripTypeScriptTypes,
}
case 'vm-script': {
const lifetime = config.scriptPersistence
? 'Variables, functions, and imported modules remain in memory for this Agent across calls.'
: 'Each call runs in a fresh Node process.'
+ const filePolicy = config.scriptPersistence
+ ? 'Direct filesystem access follows the environment\'s launch-time sandbox policy; process.env starts empty.'
+ : 'Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty.'
const asynchronousFailure = 'Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process'
+ (config.scriptPersistence ? ' and clears its variables.' : '.')
const recovery = config.scriptPersistence
@@ -160,7 +163,7 @@ function resolveMode(config: ResolvedConfig): ExecutionMode {
return {
format: 'script',
retainsState: config.scriptPersistence,
- executionInstructions: 'The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. ' + lifetime + ' Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution\'s sandbox policy; process.env starts empty. ' + asynchronousFailure + ' Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false.' + recovery,
+ executionInstructions: 'The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. ' + lifetime + ' Use await import(...) for modules. Relative paths use the supplied working directory. ' + filePolicy + ' ' + asynchronousFailure + ' Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false.' + recovery,
stripTypes: stripTypeScriptTypes,
}
}
diff --git a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts
index 2ad0d983e7..17708de189 100644
--- a/packages/ptc-runtime/ptc-runtime-node/src/worker.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/src/worker.ts
@@ -186,7 +186,8 @@ export class ProgramWorker {
if (spec.signal?.aborted) onAbort()
else {
try { evaluation.data.code = this.mode.stripTypes(spec.program) } catch (error: unknown) {
- evaluation.finish({ kind: 'exception', message: messageOf(error) })
+ // A parse failure before startup has no process state or authority to retain.
+ evaluation.finish({ kind: 'exception', message: messageOf(error) }, undefined, !this.ready)
return await result.promise
}
if (this.ready) this.dispatch(evaluation)
diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
index d364fb88f0..62c4e1896a 100644
--- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
@@ -80,6 +80,43 @@ it.skipIf(process.platform === 'win32').each(['persistent-in-memory', 'vm-script
expect(await readFile(target, 'utf8')).toBe('renewed')
})
+// Windows ACL rejects additional roots before launching a process.
+it.skipIf(process.platform === 'win32').each([
+ { executionMode: 'persistent-in-memory', change: 'add' },
+ { executionMode: 'persistent-in-memory', change: 'remove' },
+ { executionMode: 'vm-script', change: 'add' },
+ { executionMode: 'vm-script', change: 'remove' },
+] as const)('uses current grants for the first $executionMode process after a syntax error and grant $change', async ({ executionMode, change }) => {
+ const { root, cwd, ctx, runtime, owner } = await setup('workspace-write', executionMode)
+ const extra = join(root, 'extra')
+ await mkdir(extra)
+ const target = join(extra, 'memory.txt')
+ const session = ctx.sessions.create(SessionId('first-launch-grants'), { meta: { cwd } })
+ const request = { bindings: [], stateOwner: owner }
+ const remove = change === 'remove' ? ctx.sandboxPolicy.registerWritableRoot(session, extra) : undefined
+ const spawn = vi.spyOn(ctx.subprocess, 'spawn')
+ onTestFinished(() => { spawn.mockRestore() })
+ expect((await runtime.run(runtime.resolve({
+ ...request, program: 'const =', sandboxPolicy: ctx.sandboxPolicy.resolve({ session }),
+ }))).error?.kind).toBe('exception')
+ expect(spawn).not.toHaveBeenCalled()
+ if (change === 'add') ctx.sandboxPolicy.registerWritableRoot(session, extra)
+ else remove?.()
+
+ const result = await runtime.run(runtime.resolve({
+ ...request, sandboxPolicy: ctx.sandboxPolicy.resolve({ session }),
+ program: `await (await import("node:fs/promises")).writeFile(${JSON.stringify(target)}, "memory")`,
+ }))
+ expect(spawn).toHaveBeenCalledTimes(1)
+ if (change === 'add') {
+ expect(result.error).toBeUndefined()
+ expect(await readFile(target, 'utf8')).toBe('memory')
+ } else {
+ expect(result.sandbox).toMatchObject({ mode: 'workspace-write', denied: true })
+ await expect(readFile(target)).rejects.toMatchObject({ code: 'ENOENT' })
+ }
+})
+
it('fails closed when confinement is unavailable without spawning Node', async () => {
const { ctx, run } = await setup()
const confine = vi.spyOn(ctx.sandbox, 'confine').mockRejectedValue(new SandboxUnavailableError('read-only'))
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index 65121d087e..b236af8e65 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 069de80f17900193
zh: 2833f134c8dcb2e4
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
- en: c87ff1fab6cc3a2e
- zh: 4970ba31546a0e25
+ en: 504e8c18f3563fd7
+ zh: 642d8cc23cbbd1e6
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
en: 957d72691e470e5c
zh: c22a3a750af0e4a3
@@ -78,8 +78,8 @@
en: a16a657e0e5145fb
zh: 4b19263a728990dc
/deepseek-ai-dsh-sandbox-policy/known-limitations-and-deferred-work:
- en: 97f355c35ab69187
- zh: d64611e9b17e1ae0
+ en: 8582027d60300aaf
+ zh: c30cfcebf8a63e99
/deepseek-ai-dsh-sandbox-policy/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index 38c8ef41ad..ef50dde06f 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Prepare the directory on the execution host before granting it; the same path checks as configured roots apply.
-Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only, and the [Windows subprocess restriction](../sandbox-local/README.md#known-limitations-and-deferred-work) still applies.
+Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, extra grants cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
### Switching a session's mode
@@ -158,7 +158,7 @@ The stable system prompt remains byte-identical across mode changes. A changed f
These limits define the policy surface this package provides. They are current package constraints, not a general sandbox comparison or a task backlog.
-- **Deployment-scoped extra grants** — additional directories come from configuration; they are not per-session permission settings. Backend support is defined by [sandbox-local](../sandbox-local/README.md).
+- **Runtime grants are not persisted** — configured directories are shared across sessions; effect registrations belong to one live Session and must be renewed after restore or fork. Backend support is defined by [sandbox-local](../sandbox-local/README.md).
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.
- **Temporary areas are deliberately summarized** — enforcing backends grant different platform temporary areas, which are selected after policy resolution and therefore cannot be enumerated truthfully in the current context.
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index 1369bf3f0a..0e13bb9024 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -56,7 +56,7 @@ kind: "package-reference"
插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。授权前先在执行主机上准备好目录;路径检查与配置根目录相同。
-注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session 与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读,[Windows 子进程限制](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)仍然适用。
+注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,额外授权会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
### 切换会话模式
@@ -158,7 +158,7 @@ Current DSH file policy: danger-full-access. The DSH file sandbox does not restr
这些限制界定了本包提供的策略范围。它们是当前的包级约束,并非通用沙箱对比,也不是待办事项清单。
-- **部署级额外授权**——额外目录来自配置,而非逐会话权限设置。后端支持情况见 [sandbox-local](../sandbox-local/README.zh.md)。
+- **运行时授权不持久化**——配置目录由各会话共享;effect 注册属于一个存活 Session,恢复或派生后必须重新注册。后端支持情况见 [sandbox-local](../sandbox-local/README.zh.md)。
- **仅限文件操作模式**——`SandboxMode` 管控文件操作;网络和进程策略不在其词汇中,因此这里没有限制它们的旋钮。
- **有意概述临时区域**——强制执行后端会授予不同的平台临时区域,这些区域在策略解析后才会选定,因此无法在当前上下文中如实枚举。
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index edc1110601..ddf2889921 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -2,8 +2,9 @@
* The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
* deployment's sandbox fallbacks plus per-session resolution: the file-effect
* {@link SandboxMode}, the `workspace-write` root, the explicit extra writable
- * directories, and the override kit (the `sandbox/mode` event, its fold, and
- * its write path; the fold is the `sandboxMode` session-projection unit
+ * directories from configuration and live Session registrations, and the override
+ * kit (the `sandbox/mode` event, its fold, and its write path; the fold is the
+ * `sandboxMode` session-projection unit
* registered here, while the event and write path come from
* `./session-mode.ts`).
* Before each agent request, the owner also contributes the resolved policy to
@@ -50,7 +51,7 @@ function resolveAdditionalWritableRoots(roots: readonly string[] | undefined): r
if (roots === undefined || roots.length === 0) return undefined
for (const root of roots) {
if (!isAbsolute(root) || root.includes('\0')) {
- throw new Error('sandbox-policy: additionalWritableRoots entries must be absolute execution-world paths')
+ throw new Error('sandbox-policy: writable directories must be absolute execution-world paths without NUL bytes')
}
}
return Object.freeze([...new Set(roots)])
@@ -127,8 +128,8 @@ const sandboxModeStateSchema = zod.union([
/**
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
- * default mode, fallback workspace root, and current request-time policy
- * section. Tool layers call {@link resolve} for each execution so a session's
+ * default mode, fallback workspace root, session-scoped directory grants, and
+ * request-time policy section. Tool layers call {@link resolve} for each execution so a session's
* mode log and immutable cwd travel together to every enforcing capability.
*/
export class SandboxPolicyService extends Service {
diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
index 16b6b2edd0..2de5d84eb2 100644
--- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts
+++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
@@ -90,7 +90,7 @@ describe('SandboxPolicyService', () => {
try {
await ctx.plugin(SessionProjectionRegistry)
await expect(ctx.plugin(SandboxPolicyService, { additionalWritableRoots: ['relative/extra'] }))
- .rejects.toThrow('sandbox-policy: additionalWritableRoots entries must be absolute execution-world paths')
+ .rejects.toThrow('sandbox-policy: writable directories must be absolute execution-world paths without NUL bytes')
expect(ctx.get('sandboxPolicy')).toBeUndefined()
} finally {
await ctx.fiber.dispose()
diff --git a/snapshots/session/ptc-persistent/tool-schemas.expected.json b/snapshots/session/ptc-persistent/tool-schemas.expected.json
index ba5d09f8c9..d28cfa0acf 100644
--- a/snapshots/session/ptc-persistent/tool-schemas.expected.json
+++ b/snapshots/session/ptc-persistent/tool-schemas.expected.json
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
- "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
+ "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript REPL statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
"parameters": {
"type": "object",
"properties": {
diff --git a/snapshots/session/ptc-script-retained/tool-schemas.expected.json b/snapshots/session/ptc-script-retained/tool-schemas.expected.json
index dcab3a85ff..c3e65ba191 100644
--- a/snapshots/session/ptc-script-retained/tool-schemas.expected.json
+++ b/snapshots/session/ptc-script-retained/tool-schemas.expected.json
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
- "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
+ "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
"parameters": {
"type": "object",
"properties": {
From f8633966f59c409ebe15eb017b5e83390fd8f3f9 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Tue, 27 Oct 2026 11:37:19 +0800
Subject: [PATCH 016/106] fix(sandbox): skip non-directory writable roots at
execution
---
...ssion-writable-directory-effects.i18n.yaml | 4 +--
...0-26-session-writable-directory-effects.md | 2 ++
...6-session-writable-directory-effects.zh.md | 2 ++
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
docs/subsystems/sandbox.md | 6 ++--
docs/subsystems/sandbox.zh.md | 6 ++--
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 12 ++++++++
.../sandbox/sandbox-local/README.i18n.yaml | 8 +++---
packages/sandbox/sandbox-local/README.md | 4 +--
packages/sandbox/sandbox-local/README.zh.md | 4 +--
packages/sandbox/sandbox-local/src/index.ts | 4 +--
.../sandbox-local/tests/extra-roots.ts | 7 +++--
.../sandbox/sandbox-local/tests/local.spec.ts | 7 ++++-
.../sandbox/sandbox-policy/README.i18n.yaml | 8 +++---
packages/sandbox/sandbox-policy/README.md | 6 ++--
packages/sandbox/sandbox-policy/README.zh.md | 6 ++--
packages/sandbox/sandbox-policy/src/index.ts | 4 +--
packages/sandbox/sandbox/README.i18n.yaml | 4 +--
packages/sandbox/sandbox/README.md | 2 +-
packages/sandbox/sandbox/README.zh.md | 2 +-
packages/sandbox/sandbox/src/index.ts | 4 +--
packages/sandbox/sandbox/src/roots.ts | 15 ++++++----
packages/sandbox/sandbox/tests/roots.spec.ts | 28 ++++++++++++++-----
.../tests/fixtures/session-write-grant.ts | 10 +++++--
26 files changed, 103 insertions(+), 58 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
index 77a0e4417c..4800f8dbee 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
@@ -9,8 +9,8 @@
en: 2a32db1f27371403
zh: 89cf04705ef66769
/agent-note-session-writable-directory-effects/decision:
- en: a6618e4ee649c198
- zh: 5756dad5dd01dede
+ en: 68f9498fd4d92145
+ zh: 34aac1aa8bf96b91
/agent-note-session-writable-directory-effects/alternatives-considered:
en: dad77744dea17bde
zh: bb3838f9f9099944
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
index 1292e43178..63dbe7acdc 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
@@ -14,6 +14,8 @@ The sandbox-policy service owns effect-scoped directory registrations keyed by l
Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged.
+Missing and non-directory extra paths grant nothing for that execution, so an optional plugin directory cannot block unrelated workspace operations. Enforcement checks them again on each call; a directory created later becomes eligible without re-registration. Symlinked directories remain rejected.
+
## Alternatives considered
**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service.
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
index 8c934b20ff..934105efc0 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
@@ -14,6 +14,8 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。
+不存在或不是目录的额外路径在该次执行中不授予权限,因此插件的可选目录不会阻塞无关的工作区操作。执行限制时每次重新检查这些路径;稍后创建的目录无需重新注册即可获得授权。符号链接目录仍被拒绝。
+
## 考虑过的替代方案
**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 2d91981949..44bf462594 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2985,7 +2985,7 @@ export interface Config {
* (default: none). The supervisor names them explicitly; there is no
* automatic repository or gitfile discovery. Entries are execution-world
* paths: this service validates absoluteness and detaches the list, while
- * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
+ * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under
* `read-only`.
*/
additionalWritableRoots?: string[]
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index b586ca6aa2..414f7f9b84 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -2987,7 +2987,7 @@ export interface Config {
* (default: none). The supervisor names them explicitly; there is no
* automatic repository or gitfile discovery. Entries are execution-world
* paths: this service validates absoluteness and detaches the list, while
- * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
+ * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under
* `read-only`.
*/
additionalWritableRoots?: string[]
diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md
index 940bbfbf43..60cf939f09 100644
--- a/docs/subsystems/sandbox.md
+++ b/docs/subsystems/sandbox.md
@@ -55,8 +55,8 @@ interface SandboxExecutionPolicy {
workspaceRoot: string
/**
* Extra physical absolute directories `workspace-write` may also write under.
- * Absence means none. The enforcing host rejects missing, non-directory, and
- * symlinked entries on each call. Remote paths are checked on the remote host.
+ * Absence means none. The enforcing host skips missing and non-directory entries
+ * and rejects symlinked directories on each call. Remote paths are checked on the remote host.
* These grant nothing under `read-only` or `danger-full-access`.
*/
additionalWritableRoots?: readonly string[]
@@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
* operations and persistent processes retain their resolved permissions.
* Grants are runtime-only and must be registered again after resume or fork.
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
- * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.
+ * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.
* @returns the exact Cordis effect disposer for this registration.
* @throws when the directory is relative or contains a NUL byte.
*/
diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md
index 39cf22cf1c..1b0b00ed34 100644
--- a/docs/subsystems/sandbox.zh.md
+++ b/docs/subsystems/sandbox.zh.md
@@ -55,8 +55,8 @@ interface SandboxExecutionPolicy {
workspaceRoot: string
/**
* Extra physical absolute directories `workspace-write` may also write under.
- * Absence means none. The enforcing host rejects missing, non-directory, and
- * symlinked entries on each call. Remote paths are checked on the remote host.
+ * Absence means none. The enforcing host skips missing and non-directory entries
+ * and rejects symlinked directories on each call. Remote paths are checked on the remote host.
* These grant nothing under `read-only` or `danger-full-access`.
*/
additionalWritableRoots?: readonly string[]
@@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
* operations and persistent processes retain their resolved permissions.
* Grants are runtime-only and must be registered again after resume or fork.
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
- * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.
+ * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.
* @returns the exact Cordis effect disposer for this registration.
* @throws when the directory is relative or contains a NUL byte.
*/
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index b8c6b39cb9..a95a291550 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -2586,7 +2586,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: 'registerWritableRoot(session: Session, directory: string): () => void',
description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.',
- parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.' }],
+ parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.' }],
returns: 'the exact Cordis effect disposer for this registration.',
throws: ['when the directory is relative or contains a NUL byte.'],
},
diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
index e0a00e2adb..80894f820f 100644
--- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
+++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
@@ -117,6 +117,18 @@ describe('workspace-write containment', () => {
expect(await readFile(path, 'utf8')).toBe('updated')
})
+ it('ignores non-directory grants without blocking workspace or granted-directory writes', async () => {
+ const file = join(outside, 'file.txt')
+ await writeFile(file, 'file')
+ const policy = { mode: 'workspace-write' as const, workspaceRoot: workspace,
+ additionalWritableRoots: [outside, join(base, 'missing'), file, join(file, 'child')] }
+ for (const directory of [workspace, outside]) {
+ const path = join(directory, 'allowed.txt')
+ await fs.writeText(await target(path), 'allowed', undefined, undefined, policy)
+ expect(await readFile(path, 'utf8')).toBe('allowed')
+ }
+ })
+
it('rejects an extra directory replaced by a symlink to an ungranted sibling', async () => {
const extra = join(workspace, 'extra')
await mkdir(extra)
diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml
index 32a828bd3a..64c10233ec 100644
--- a/packages/sandbox/sandbox-local/README.i18n.yaml
+++ b/packages/sandbox/sandbox-local/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 7282c9ec77fd6f01
zh: 10e385a9489105e3
/deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery:
- en: 9b648f1bfdd55e76
- zh: d9265bdf2e272581
+ en: 5f0db8dd0b75afb7
+ zh: b523950be1ac3860
/deepseek-ai-dsh-sandbox-local/understand-the-implementation:
en: 15db6ec303151ccc
zh: 91f62c1f64192f92
@@ -54,8 +54,8 @@
en: e290ccc9b3c92c08
zh: 469589ac12cdadab
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work:
- en: 7cd5c6a4cc971803
- zh: 4e4f495b654d95c5
+ en: 38756b82fe0cd48c
+ zh: 441cc45ebe5b51fd
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note:
en: cc273320f6a1beaf
zh: 5c57a312ddc50651
diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md
index de7a26c4bc..9c08830c4f 100644
--- a/packages/sandbox/sandbox-local/README.md
+++ b/packages/sandbox/sandbox-local/README.md
@@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E
An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly.
-Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Invalid extra directories raise `SANDBOX_INVALID_ROOT` before the command starts.
+Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing and non-directory paths are skipped at execution time. Symlinked directories and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts.
-----
@@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
-- **Windows extra roots** — Windows ACL rejects every workspace-write subprocess carrying extra directories with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations.
+- **Windows extra roots** — Windows ACL rejects workspace-write subprocesses with existing writable directories beyond the workspace with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations.
- **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
- **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.
diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md
index bb03e87ea6..08f2821cb1 100644
--- a/packages/sandbox/sandbox-local/README.zh.md
+++ b/packages/sandbox/sandbox-local/README.zh.md
@@ -56,7 +56,7 @@ kind: "package-reference"
不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。
-Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。无效的额外目录会在命令启动前触发 `SANDBOX_INVALID_ROOT`。
+Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。执行时跳过不存在或不是目录的路径。符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。
-----
@@ -130,7 +130,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同
这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。
-- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝所有携带额外目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。
+- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝授权工作区以外现存目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。
- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
- **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
- **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts
index acf4b4e738..5f46c01b51 100644
--- a/packages/sandbox/sandbox-local/src/index.ts
+++ b/packages/sandbox/sandbox-local/src/index.ts
@@ -34,7 +34,7 @@ import {
} from '@deepseek-ai/node-addon-system/landlock-run'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath } from '@deepseek-ai/dsh-sandbox'
+import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
@@ -367,7 +367,7 @@ export class LocalSandboxProvider extends SandboxProvider {
* @returns the runner invocation.
*/
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
- if (policy.mode === 'workspace-write' && (policy.additionalWritableRoots?.length ?? 0) > 0) {
+ if (workspaceWritableRoots(policy).length > 1) {
throw new SandboxUnsupportedPolicyError('Windows ACL confinement does not support additional writable directories; remove the extra grants or use a supported host')
}
const sessionId = policy.sessionId
diff --git a/packages/sandbox/sandbox-local/tests/extra-roots.ts b/packages/sandbox/sandbox-local/tests/extra-roots.ts
index ba4d40a773..79229fd64c 100644
--- a/packages/sandbox/sandbox-local/tests/extra-roots.ts
+++ b/packages/sandbox/sandbox-local/tests/extra-roots.ts
@@ -1,6 +1,6 @@
/** Native backend checks for explicit directory grants and their denial cases. */
import { spawnSync } from 'node:child_process'
-import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { expect } from 'vitest'
@@ -29,7 +29,10 @@ export async function checkExtraWrites(provider: LocalSandboxProvider, directory
const workspaceRoot = realpathSync.native(await directory(homedir()))
const extra = realpathSync.native(await directory(homedir()))
const outside = realpathSync.native(await directory(homedir()))
- const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot, additionalWritableRoots: [extra] }
+ const file = join(workspaceRoot, 'file.txt')
+ writeFileSync(file, 'file')
+ const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot,
+ additionalWritableRoots: [extra, join(workspaceRoot, 'missing'), file, join(file, 'child')] }
const allowed = join(extra, 'allowed.txt')
const result = await run(provider, `printf allowed > ${quote(allowed)}`, policy)
expect(result.status, result.stderr).toBe(0)
diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts
index e80b7e8476..05e4abfb3f 100644
--- a/packages/sandbox/sandbox-local/tests/local.spec.ts
+++ b/packages/sandbox/sandbox-local/tests/local.spec.ts
@@ -436,8 +436,13 @@ describe('the windows-acl probe (runner invocation contract)', () => {
probeWindowsAcl: () => true,
windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
})
+ const extra = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-extra-acl-')))
+ tempDirs.push(extra)
+ const file = join(extra, 'file.txt')
+ writeFileSync(file, 'file')
try {
- await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: ['/extra'] }))
+ await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).resolves.toBeDefined()
+ await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] }))
.rejects.toThrow('Windows ACL confinement does not support additional writable directories')
await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined()
} finally {
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index b236af8e65..44ab7211d2 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -21,11 +21,11 @@
en: 6704447e52ec295b
zh: 9ed812d9e29d5533
/deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration:
- en: 069de80f17900193
- zh: 2833f134c8dcb2e4
+ en: b1f04aadeb80f810
+ zh: 3be13588a912b8a7
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
- en: 504e8c18f3563fd7
- zh: 642d8cc23cbbd1e6
+ en: adbc1838f04d9f53
+ zh: 83a0a76cc3b36576
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
en: 957d72691e470e5c
zh: c22a3a750af0e4a3
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index ef50dde06f..ba59138092 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -48,15 +48,15 @@ Load the package with a default mode; the fail-safe default is `read-only`, and
| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd |
| `additionalWritableRoots` | `[]` | Explicit physical absolute directories also writable under `workspace-write`; other confined modes grant no access to them |
-For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, files, and symlinked roots, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host.
+For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host skips missing paths and non-directories on each call; they grant nothing for that execution. It rejects symlinked directories, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host.
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-sandbox-policy) is the exhaustive source for every accepted field and its JSDoc.
### Registering a session's writable directory
-A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Prepare the directory on the execution host before granting it; the same path checks as configured roots apply.
+A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. The directory may be registered before it exists; subsequent executions grant it once it is a directory on the execution host. The same path checks as configured roots apply.
-Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, extra grants cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
+Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
### Switching a session's mode
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index 0e13bb9024..0fdf06fe20 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -48,15 +48,15 @@ kind: "package-reference"
| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd |
| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的显式物理绝对目录;其他受限模式不授予这些目录的写权限 |
-对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机拒绝不存在的路径、文件和符号链接根目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。
+对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时跳过不存在或不是目录的路径;这些路径在该次执行中不授予权限。它拒绝符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-sandbox-policy)是每个受支持字段及其 JSDoc 的穷尽式真源。
### 注册会话的可写目录
-插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。授权前先在执行主机上准备好目录;路径检查与配置根目录相同。
+插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。目录尚不存在时也可以注册;当它在执行主机上成为目录后,后续执行便会授予权限。路径检查与配置根目录相同。
-注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,额外授权会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
+注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
### 切换会话模式
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index ddf2889921..e18b9becc1 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -105,7 +105,7 @@ export interface Config {
* (default: none). The supervisor names them explicitly; there is no
* automatic repository or gitfile discovery. Entries are execution-world
* paths: this service validates absoluteness and detaches the list, while
- * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
+ * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under
* `read-only`.
*/
additionalWritableRoots?: string[]
@@ -219,7 +219,7 @@ export class SandboxPolicyService extends Service {
* operations and persistent processes retain their resolved permissions.
* Grants are runtime-only and must be registered again after resume or fork.
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
- * @param directory - absolute execution-world directory; the enforcing host checks existence and rejects symlinked grants.
+ * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.
* @returns the exact Cordis effect disposer for this registration.
* @throws when the directory is relative or contains a NUL byte.
*/
diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml
index ffd7e69baf..1953226bc3 100644
--- a/packages/sandbox/sandbox/README.i18n.yaml
+++ b/packages/sandbox/sandbox/README.i18n.yaml
@@ -45,8 +45,8 @@
en: 1b418d41f571ab3d
zh: 62b289cdd6f4d272
/deepseek-ai-dsh-sandbox/understand-the-implementation/writable-roots:
- en: 00dcc13597416429
- zh: eaff9233d0611ad0
+ en: a5e34cecd6d0d5ce
+ zh: 5b16bd952f975c44
/deepseek-ai-dsh-sandbox/further-exploration:
en: 33d09277fb15d59f
zh: 920ffe66671b56f3
diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md
index 7014cc4730..617e28f351 100644
--- a/packages/sandbox/sandbox/README.md
+++ b/packages/sandbox/sandbox/README.md
@@ -100,7 +100,7 @@ The ladder is a closed table — `read-only` may escalate to `workspace-write` o
### Writable roots
-`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. See the [mode definitions](../../../docs/subsystems/sandbox.md).
+`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots, skip extra paths that are missing or are not directories, and reject directories that resolve through symlinks. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md).
diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md
index bc94b7f090..a32213762f 100644
--- a/packages/sandbox/sandbox/README.zh.md
+++ b/packages/sandbox/sandbox/README.zh.md
@@ -100,7 +100,7 @@ kind: "package-reference"
### 可写根目录
-`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
+`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,跳过不存在或不是目录的额外路径,并拒绝经过符号链接解析的目录。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts
index 0fa3183987..d8b6d99b80 100644
--- a/packages/sandbox/sandbox/src/index.ts
+++ b/packages/sandbox/sandbox/src/index.ts
@@ -44,8 +44,8 @@ export interface SandboxExecutionPolicy {
workspaceRoot: string
/**
* Extra physical absolute directories `workspace-write` may also write under.
- * Absence means none. The enforcing host rejects missing, non-directory, and
- * symlinked entries on each call. Remote paths are checked on the remote host.
+ * Absence means none. The enforcing host skips missing and non-directory entries
+ * and rejects symlinked directories on each call. Remote paths are checked on the remote host.
* These grant nothing under `read-only` or `danger-full-access`.
*/
additionalWritableRoots?: readonly string[]
diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts
index b2f1c39c24..276ef5f123 100644
--- a/packages/sandbox/sandbox/src/roots.ts
+++ b/packages/sandbox/sandbox/src/roots.ts
@@ -27,17 +27,19 @@ export class SandboxInvalidRootError extends HarnessError {
}
}
-/** Reject mutable path aliases before they can redirect an explicit grant. */
-function additionalRoot(path: string): string {
+/** Resolve existing directories without allowing mutable aliases to redirect grants. */
+function additionalRoot(path: string): string | undefined {
let physical: string
let directory: boolean
try {
physical = realpathSync.native(path)
directory = statSync(physical).isDirectory()
} catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ if (code === 'ENOENT' || code === 'ENOTDIR') return undefined
throw new SandboxInvalidRootError(path, `cannot read the directory: ${String(error)}`)
}
- if (!directory) throw new SandboxInvalidRootError(path, 'expected an existing directory')
+ if (!directory) return undefined
if (physical !== resolve(path)) {
throw new SandboxInvalidRootError(path, `use the physical directory path ${JSON.stringify(physical)}; symlinked grants are not supported`)
}
@@ -69,7 +71,7 @@ export function canonicalPath(path: string): string {
/**
* The workspace-owned writable roots — the policy's workspace root plus its
- * explicit extra directories, as a canonical, deduplicated allow-list. Both
+ * existing extra directories, as a canonical, deduplicated allow-list. Both
* the workspace and every extra root must be granted together by every
* backend; this is the one derivation of that set, kept separate from the
* platform temp policy each backend documents for itself (Seatbelt and the fs
@@ -77,11 +79,12 @@ export function canonicalPath(path: string): string {
* grants).
* @param policy - the file-effect policy to derive the allow-list from.
* @returns the canonical workspace-owned roots; empty under `read-only`.
- * @throws {SandboxInvalidRootError} An extra root is missing, is not a directory, or uses a symlink.
+ * @throws {SandboxInvalidRootError} An extra root cannot be inspected or uses a symlink; missing and non-directory entries are skipped.
*/
export function workspaceWritableRoots(policy: SandboxExecutionPolicy): string[] {
if (policy.mode !== 'workspace-write') return []
- return [...new Set([canonicalPath(policy.workspaceRoot), ...(policy.additionalWritableRoots ?? []).map(additionalRoot)])]
+ const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot).filter(root => root !== undefined)
+ return [...new Set([canonicalPath(policy.workspaceRoot), ...additional])]
}
/**
diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts
index b7b5636882..8f8864f57e 100644
--- a/packages/sandbox/sandbox/tests/roots.spec.ts
+++ b/packages/sandbox/sandbox/tests/roots.spec.ts
@@ -5,10 +5,10 @@
* additional set stays one shared derivation across backend dialects.
*/
-import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { canonicalPath, workspaceWritableRoots, writableRoots } from '@deepseek-ai/dsh-sandbox'
/** Every temp root created by this file, removed after each test. */
@@ -52,15 +52,29 @@ describe('workspaceWritableRoots', () => {
.toThrow('use the physical directory path')
})
- it('rejects missing and non-directory extra roots before granting access', () => {
+ it('skips non-directory extra roots and checks them again on each call', () => {
const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-')))
roots.push(ws)
const file = join(ws, 'file.txt')
+ const extra = join(ws, 'extra')
writeFileSync(file, 'file')
- expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [file] }))
- .toThrow('expected an existing directory')
- expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [join(ws, 'missing')] }))
- .toThrow('cannot read the directory')
+ const policy = { mode: 'workspace-write' as const, workspaceRoot: ws,
+ additionalWritableRoots: [file, join(file, 'child'), extra] }
+ expect(workspaceWritableRoots(policy)).toEqual([ws])
+ mkdirSync(extra)
+ expect(workspaceWritableRoots(policy)).toEqual([ws, extra])
+ rmSync(extra, { recursive: true })
+ expect(workspaceWritableRoots(policy)).toEqual([ws])
+ })
+
+ it('reports inspection failures that do not establish a missing or non-directory path', () => {
+ const inspect = vi.spyOn(realpathSync, 'native').mockImplementationOnce(() => {
+ throw Object.assign(new Error('access denied'), { code: 'EACCES' })
+ })
+ try {
+ expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: '/workspace', additionalWritableRoots: ['/private'] }))
+ .toThrow('cannot read the directory')
+ } finally { inspect.mockRestore() }
})
it('read-only grants nothing even when extra roots are carried on the policy', () => {
diff --git a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
index 7e2605cab6..96087efe88 100644
--- a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
+++ b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
@@ -10,14 +10,18 @@ export const name = 'session-write-grant'
export const inject = ['sandboxPolicy']
/**
- * Grant one directory until the first tool result, before the next request.
+ * Grant directories until the first tool result, before the next request.
* @param ctx - fixture lifetime and policy service.
*/
export function apply(ctx: Context): void {
ctx.on('agent/created', ({ agent }) => {
- const dispose = ctx.sandboxPolicy.registerWritableRoot(agent.session, join(process.cwd(), 'extra'))
+ const extra = join(process.cwd(), 'extra')
+ const disposers = [extra, join(extra, 'missing'), join(extra, 'keep.txt')]
+ .map(directory => ctx.sandboxPolicy.registerWritableRoot(agent.session, directory))
ctx.on('session/event', (session, event) => {
- if (session === agent.session && event.type === 'tool/result') dispose()
+ if (session === agent.session && event.type === 'tool/result') {
+ for (const dispose of disposers) dispose()
+ }
})
})
}
From 438e84d7ef3b8a6d74f0817c50fdc963edd573ae Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Tue, 3 Nov 2026 15:30:10 +0800
Subject: [PATCH 017/106] test(sandbox): retain agent identity in refreshed
policy context
---
snapshots/session/session-sandbox-root/session.v4.jsonl | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl
index 8290342b9e..c003c74d67 100644
--- a/snapshots/session/session-sandbox-root/session.v4.jsonl
+++ b/snapshots/session/session-sandbox-root/session.v4.jsonl
@@ -20,7 +20,7 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_session_root"},"toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt \nfile \n\nCreated file\n "}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","path":"{{cwd}}/session-root.txt","diffs":[]}},"sourceEventSeqs":[17],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1793015794554,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793015794554,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793015794554,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
From a449c9d734bfe68c9560efe172212a497e6c4d48 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Tue, 3 Nov 2026 17:03:30 +0800
Subject: [PATCH 018/106] test(ptc): refresh persistent import tool description
---
snapshots/session/ptc-script-imports/tool-schemas.expected.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/snapshots/session/ptc-script-imports/tool-schemas.expected.json b/snapshots/session/ptc-script-imports/tool-schemas.expected.json
index dcab3a85ff..c3e65ba191 100644
--- a/snapshots/session/ptc-script-imports/tool-schemas.expected.json
+++ b/snapshots/session/ptc-script-imports/tool-schemas.expected.json
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
- "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
+ "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
"parameters": {
"type": "object",
"properties": {
From e6cd6fdc617ed70ac2d121cda9d095c9c93f1dfc Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 4 Nov 2026 14:18:39 +0800
Subject: [PATCH 019/106] fix(sandbox): reject invalid writable directory
grants
---
...ssion-writable-directory-effects.i18n.yaml | 4 +--
...0-26-session-writable-directory-effects.md | 2 +-
...6-session-writable-directory-effects.zh.md | 2 +-
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
docs/subsystems/sandbox.md | 6 ++---
docs/subsystems/sandbox.zh.md | 6 ++---
.../extensions/tool-cordis/src/api-catalog.ts | 2 +-
.../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 15 ++++++-----
.../sandbox/sandbox-local/README.i18n.yaml | 4 +--
packages/sandbox/sandbox-local/README.md | 2 +-
packages/sandbox/sandbox-local/README.zh.md | 2 +-
.../sandbox-local/tests/extra-roots.ts | 6 ++---
.../sandbox/sandbox-local/tests/local.spec.ts | 2 +-
.../sandbox/sandbox-policy/README.i18n.yaml | 8 +++---
packages/sandbox/sandbox-policy/README.md | 4 +--
packages/sandbox/sandbox-policy/README.zh.md | 4 +--
packages/sandbox/sandbox-policy/src/index.ts | 4 +--
packages/sandbox/sandbox/README.i18n.yaml | 4 +--
packages/sandbox/sandbox/README.md | 2 +-
packages/sandbox/sandbox/README.zh.md | 2 +-
packages/sandbox/sandbox/src/index.ts | 4 +--
packages/sandbox/sandbox/src/roots.ts | 18 ++++++-------
packages/sandbox/sandbox/tests/roots.spec.ts | 26 +++++++++++++++----
.../tests/fixtures/session-write-grant.ts | 5 ++--
25 files changed, 75 insertions(+), 63 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
index 4800f8dbee..bcc2fe33cb 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
@@ -9,8 +9,8 @@
en: 2a32db1f27371403
zh: 89cf04705ef66769
/agent-note-session-writable-directory-effects/decision:
- en: 68f9498fd4d92145
- zh: 34aac1aa8bf96b91
+ en: af67969841bedb67
+ zh: edabdfaa30bcadd1
/agent-note-session-writable-directory-effects/alternatives-considered:
en: dad77744dea17bde
zh: bb3838f9f9099944
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
index 63dbe7acdc..1608b93010 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
@@ -14,7 +14,7 @@ The sandbox-policy service owns effect-scoped directory registrations keyed by l
Registration disposal affects subsequent operations, not already-resolved calls or running processes. This deliberately permits persistent terminals and PTC environments to retain their launch grants until they stop. The requirement is to update ongoing Sessions' future executions without coordinating process cancellation or losing retained state. This narrows the different-policy reuse restriction in the [retained PTC sandbox decision](2026-10-08-persistent-ptc-sandbox.md) for additional directories only; mode-change invalidation and one-time escalation cleanup remain unchanged.
-Missing and non-directory extra paths grant nothing for that execution, so an optional plugin directory cannot block unrelated workspace operations. Enforcement checks them again on each call; a directory created later becomes eligible without re-registration. Symlinked directories remain rejected.
+Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
index 934105efc0..3cc4f01c3f 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
@@ -14,7 +14,7 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
注册清理影响后续操作,不影响已经解析策略的调用或运行中的进程。这明确允许持久终端与 PTC 环境在停止前保留启动时的授权。需求是在不协调进程取消、不丢失保留状态的情况下更新运行中 Session 的后续执行。这仅针对额外目录收窄了[保留 PTC 沙箱决策](2026-10-08-persistent-ptc-sandbox.zh.md)中不同策略禁止复用的限制;模式变化失效与单次提权清理保持不变。
-不存在或不是目录的额外路径在该次执行中不授予权限,因此插件的可选目录不会阻塞无关的工作区操作。执行限制时每次重新检查这些路径;稍后创建的目录无需重新注册即可获得授权。符号链接目录仍被拒绝。
+不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。
## 考虑过的替代方案
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 44bf462594..2d91981949 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2985,7 +2985,7 @@ export interface Config {
* (default: none). The supervisor names them explicitly; there is no
* automatic repository or gitfile discovery. Entries are execution-world
* paths: this service validates absoluteness and detaches the list, while
- * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under
+ * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
* `read-only`.
*/
additionalWritableRoots?: string[]
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 414f7f9b84..b586ca6aa2 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -2987,7 +2987,7 @@ export interface Config {
* (default: none). The supervisor names them explicitly; there is no
* automatic repository or gitfile discovery. Entries are execution-world
* paths: this service validates absoluteness and detaches the list, while
- * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under
+ * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
* `read-only`.
*/
additionalWritableRoots?: string[]
diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md
index 60cf939f09..a3539c85c2 100644
--- a/docs/subsystems/sandbox.md
+++ b/docs/subsystems/sandbox.md
@@ -55,8 +55,8 @@ interface SandboxExecutionPolicy {
workspaceRoot: string
/**
* Extra physical absolute directories `workspace-write` may also write under.
- * Absence means none. The enforcing host skips missing and non-directory entries
- * and rejects symlinked directories on each call. Remote paths are checked on the remote host.
+ * Absence means none. The enforcing host rejects missing, non-directory, and
+ * symlinked entries on each call. Remote paths are checked on the remote host.
* These grant nothing under `read-only` or `danger-full-access`.
*/
additionalWritableRoots?: readonly string[]
@@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
* operations and persistent processes retain their resolved permissions.
* Grants are runtime-only and must be registered again after resume or fork.
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
- * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.
+ * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.
* @returns the exact Cordis effect disposer for this registration.
* @throws when the directory is relative or contains a NUL byte.
*/
diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md
index 1b0b00ed34..78b8b63113 100644
--- a/docs/subsystems/sandbox.zh.md
+++ b/docs/subsystems/sandbox.zh.md
@@ -55,8 +55,8 @@ interface SandboxExecutionPolicy {
workspaceRoot: string
/**
* Extra physical absolute directories `workspace-write` may also write under.
- * Absence means none. The enforcing host skips missing and non-directory entries
- * and rejects symlinked directories on each call. Remote paths are checked on the remote host.
+ * Absence means none. The enforcing host rejects missing, non-directory, and
+ * symlinked entries on each call. Remote paths are checked on the remote host.
* These grant nothing under `read-only` or `danger-full-access`.
*/
additionalWritableRoots?: readonly string[]
@@ -222,7 +222,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy
* operations and persistent processes retain their resolved permissions.
* Grants are runtime-only and must be registered again after resume or fork.
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
- * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.
+ * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.
* @returns the exact Cordis effect disposer for this registration.
* @throws when the directory is relative or contains a NUL byte.
*/
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index a95a291550..5c112c83cf 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -2586,7 +2586,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: 'registerWritableRoot(session: Session, directory: string): () => void',
description: 'Permit writes under one directory in this Session\'s future workspace-write operations. The calling context owns the effect; disposal removes only this registration. Other grants to the directory remain effective. Existing operations and persistent processes retain their resolved permissions. Grants are runtime-only and must be registered again after resume or fork.',
- parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.' }],
+ parameters: [{ name: 'session', description: 'exact live Session receiving the grant; other Sessions and agentless calls are unaffected.' }, { name: 'directory', description: 'absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.' }],
returns: 'the exact Cordis effect disposer for this registration.',
throws: ['when the directory is relative or contains a NUL byte.'],
},
diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
index 80894f820f..1134b27cfb 100644
--- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
+++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
@@ -117,15 +117,16 @@ describe('workspace-write containment', () => {
expect(await readFile(path, 'utf8')).toBe('updated')
})
- it('ignores non-directory grants without blocking workspace or granted-directory writes', async () => {
+ it('rejects invalid grants before workspace writes', async () => {
const file = join(outside, 'file.txt')
await writeFile(file, 'file')
- const policy = { mode: 'workspace-write' as const, workspaceRoot: workspace,
- additionalWritableRoots: [outside, join(base, 'missing'), file, join(file, 'child')] }
- for (const directory of [workspace, outside]) {
- const path = join(directory, 'allowed.txt')
- await fs.writeText(await target(path), 'allowed', undefined, undefined, policy)
- expect(await readFile(path, 'utf8')).toBe('allowed')
+ const path = join(workspace, 'blocked.txt')
+ for (const invalid of [join(base, 'missing'), file, join(file, 'child')]) {
+ const policy = { mode: 'workspace-write' as const, workspaceRoot: workspace,
+ additionalWritableRoots: [outside, invalid] }
+ await expect(fs.writeText(await target(path), 'blocked', undefined, undefined, policy))
+ .rejects.toMatchObject({ code: 'SANDBOX_INVALID_ROOT' })
+ expect(existsSync(path)).toBe(false)
}
})
diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml
index 64c10233ec..56f69b41d7 100644
--- a/packages/sandbox/sandbox-local/README.i18n.yaml
+++ b/packages/sandbox/sandbox-local/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 7282c9ec77fd6f01
zh: 10e385a9489105e3
/deepseek-ai-dsh-sandbox-local/use-this-package/failures-and-recovery:
- en: 5f0db8dd0b75afb7
- zh: b523950be1ac3860
+ en: f7033dc3f9c073de
+ zh: e667973ac2e40ad0
/deepseek-ai-dsh-sandbox-local/understand-the-implementation:
en: 15db6ec303151ccc
zh: 91f62c1f64192f92
diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md
index 9c08830c4f..dfe65511b6 100644
--- a/packages/sandbox/sandbox-local/README.md
+++ b/packages/sandbox/sandbox-local/README.md
@@ -56,7 +56,7 @@ With the provider mounted, a command runs under the mode you resolve per call. E
An unsupported platform or an unusable runner fails closed: `confine()` rejects with `SANDBOX_UNAVAILABLE` and names the runner options for the platform, and the consumer surfaces that error rather than running the command unconfined. A runner that starts but refuses its profile is identified by its fatal stderr signature and exit code, so a broken sandbox is not mistaken for a denied command. The `runnerCommand` override is an operator assertion: it skips functional probes and assumes the configured runner implements the bwrap-compatible profile honestly.
-Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing and non-directory paths are skipped at execution time. Symlinked directories and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts.
+Seatbelt, bwrap, and Landlock include the policy's explicit `additionalWritableRoots` under `workspace-write`. Read-only ignores those grants. Missing paths, non-directories, symlinked directories, and other inspection failures raise `SANDBOX_INVALID_ROOT` before the command starts.
-----
diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md
index 08f2821cb1..152110d5f6 100644
--- a/packages/sandbox/sandbox-local/README.zh.md
+++ b/packages/sandbox/sandbox-local/README.zh.md
@@ -56,7 +56,7 @@ kind: "package-reference"
不受支持的平台或不可用的 runner 会拒绝执行:`confine()` 以 `SANDBOX_UNAVAILABLE` 拒绝 并列出该平台的 runner 选项,消费方会呈现该错误,而不是让命令不受限制地运行。启动后拒绝自身 profile 的 runner 由其致命 stderr 签名与退出码识别,因此损坏的沙箱不会被误认为被拒绝的命令。`runnerCommand` 覆盖是操作方断言:它跳过功能探测,并假定配置的 runner 诚实实现与 bwrap 兼容的 profile。
-Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。执行时跳过不存在或不是目录的路径。符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。
+Seatbelt、bwrap 和 Landlock 在 `workspace-write` 下包含策略显式指定的 `additionalWritableRoots`。只读模式忽略这些授权。不存在的路径、非目录、符号链接目录和其他检查失败会在命令启动前触发 `SANDBOX_INVALID_ROOT`。
-----
diff --git a/packages/sandbox/sandbox-local/tests/extra-roots.ts b/packages/sandbox/sandbox-local/tests/extra-roots.ts
index 79229fd64c..0248931af7 100644
--- a/packages/sandbox/sandbox-local/tests/extra-roots.ts
+++ b/packages/sandbox/sandbox-local/tests/extra-roots.ts
@@ -1,6 +1,6 @@
/** Native backend checks for explicit directory grants and their denial cases. */
import { spawnSync } from 'node:child_process'
-import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { expect } from 'vitest'
@@ -29,10 +29,8 @@ export async function checkExtraWrites(provider: LocalSandboxProvider, directory
const workspaceRoot = realpathSync.native(await directory(homedir()))
const extra = realpathSync.native(await directory(homedir()))
const outside = realpathSync.native(await directory(homedir()))
- const file = join(workspaceRoot, 'file.txt')
- writeFileSync(file, 'file')
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot,
- additionalWritableRoots: [extra, join(workspaceRoot, 'missing'), file, join(file, 'child')] }
+ additionalWritableRoots: [extra] }
const allowed = join(extra, 'allowed.txt')
const result = await run(provider, `printf allowed > ${quote(allowed)}`, policy)
expect(result.status, result.stderr).toBe(0)
diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts
index 05e4abfb3f..36eb46c9c1 100644
--- a/packages/sandbox/sandbox-local/tests/local.spec.ts
+++ b/packages/sandbox/sandbox-local/tests/local.spec.ts
@@ -441,7 +441,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
const file = join(extra, 'file.txt')
writeFileSync(file, 'file')
try {
- await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).resolves.toBeDefined()
+ await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory')
await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] }))
.rejects.toThrow('Windows ACL confinement does not support additional writable directories')
await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined()
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index 44ab7211d2..3638e6ac9f 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -21,11 +21,11 @@
en: 6704447e52ec295b
zh: 9ed812d9e29d5533
/deepseek-ai-dsh-sandbox-policy/use-this-package/minimal-configuration:
- en: b1f04aadeb80f810
- zh: 3be13588a912b8a7
+ en: c7e924a923bfac72
+ zh: fdaf930a5800ffa6
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
- en: adbc1838f04d9f53
- zh: 83a0a76cc3b36576
+ en: 169b82d5392ec91a
+ zh: e0ef9428cb794f9c
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
en: 957d72691e470e5c
zh: c22a3a750af0e4a3
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index ba59138092..431c140934 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -48,13 +48,13 @@ Load the package with a default mode; the fail-safe default is `read-only`, and
| `workspaceRoot` | `process.cwd()` | Absolute fallback root for agentless calls or sessions without a cwd; relative values fail at load. Normal agent calls use the session's immutable cwd |
| `additionalWritableRoots` | `[]` | Explicit physical absolute directories also writable under `workspace-write`; other confined modes grant no access to them |
-For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host skips missing paths and non-directories on each call; they grant nothing for that execution. It rejects symlinked directories, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host.
+For a linked Git worktree, the caller can grant its shared Git directory with `additionalWritableRoots`. The caller selects that directory explicitly; the policy does not inspect Git files or grant a parent checkout. These grants are deployment configuration shared by the composition's sessions, rather than saved Session permissions. Granting the entire Git directory also permits changes to hooks and configuration that a later host Git invocation can execute outside the sandbox; use that grant only for trusted tasks. Use physical absolute directory paths. The enforcing host rejects missing paths, non-directories, and symlinked directories on each call, including a directory replaced by a symlink between calls. Remote grants are validated on the remote host.
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-sandbox-policy) is the exhaustive source for every accepted field and its JSDoc.
### Registering a session's writable directory
-A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. The directory may be registered before it exists; subsequent executions grant it once it is a directory on the execution host. The same path checks as configured roots apply.
+A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply.
Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index 0fdf06fe20..f2d3a88632 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -48,13 +48,13 @@ kind: "package-reference"
| `workspaceRoot` | `process.cwd()` | 无 agent 调用或没有 cwd 的会话所用的绝对回退根目录;相对值在加载时拒绝。普通 agent 调用使用会话的不可变 cwd |
| `additionalWritableRoots` | `[]` | 在 `workspace-write` 下额外允许写入的显式物理绝对目录;其他受限模式不授予这些目录的写权限 |
-对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时跳过不存在或不是目录的路径;这些路径在该次执行中不授予权限。它拒绝符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。
+对于关联的 Git worktree,调用方可通过 `additionalWritableRoots` 授予共享 Git 目录的写权限。调用方必须显式选择该目录;策略不会读取 Git 文件或授权父检出目录。这些授权是组合中所有会话共享的部署配置,而非保存的 Session 权限。授权整个 Git 目录也允许修改钩子和配置,后续主机 Git 调用可能在沙箱之外执行它们;仅对可信任务使用此授权。使用物理绝对目录路径。执行限制的主机在每次调用时拒绝不存在的路径、非目录和符号链接目录,包括两次调用之间被替换为符号链接的目录。远端授权在远端主机上验证。
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-sandbox-policy)是每个受支持字段及其 JSDoc 的穷尽式真源。
### 注册会话的可写目录
-插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。目录尚不存在时也可以注册;当它在执行主机上成为目录后,后续执行便会授予权限。路径检查与配置根目录相同。
+插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。
注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index e18b9becc1..064c14b070 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -105,7 +105,7 @@ export interface Config {
* (default: none). The supervisor names them explicitly; there is no
* automatic repository or gitfile discovery. Entries are execution-world
* paths: this service validates absoluteness and detaches the list, while
- * the enforcing host skips missing or non-directory paths and rejects symlinked directories. They grant nothing under
+ * the enforcing host rejects missing, non-directory, or symlinked paths. They grant nothing under
* `read-only`.
*/
additionalWritableRoots?: string[]
@@ -219,7 +219,7 @@ export class SandboxPolicyService extends Service {
* operations and persistent processes retain their resolved permissions.
* Grants are runtime-only and must be registered again after resume or fork.
* @param session - exact live Session receiving the grant; other Sessions and agentless calls are unaffected.
- * @param directory - absolute execution-world path; execution skips it unless it is a directory and rejects symlinked directories.
+ * @param directory - absolute execution-world path; execution rejects missing, non-directory, or symlinked paths.
* @returns the exact Cordis effect disposer for this registration.
* @throws when the directory is relative or contains a NUL byte.
*/
diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml
index 1953226bc3..095aa685e5 100644
--- a/packages/sandbox/sandbox/README.i18n.yaml
+++ b/packages/sandbox/sandbox/README.i18n.yaml
@@ -45,8 +45,8 @@
en: 1b418d41f571ab3d
zh: 62b289cdd6f4d272
/deepseek-ai-dsh-sandbox/understand-the-implementation/writable-roots:
- en: a5e34cecd6d0d5ce
- zh: 5b16bd952f975c44
+ en: b5001b6dc9a0d258
+ zh: 08020a052df6856c
/deepseek-ai-dsh-sandbox/further-exploration:
en: 33d09277fb15d59f
zh: 920ffe66671b56f3
diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md
index 617e28f351..d5de61a78c 100644
--- a/packages/sandbox/sandbox/README.md
+++ b/packages/sandbox/sandbox/README.md
@@ -100,7 +100,7 @@ The ladder is a closed table — `read-only` may escalate to `workspace-write` o
### Writable roots
-`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots, skip extra paths that are missing or are not directories, and reject directories that resolve through symlinks. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md).
+`workspaceWritableRoots` derives the workspace and explicit extra directories for bwrap and Landlock. `writableRoots` adds the host temporary areas for Seatbelt and the in-process filesystem fence. Both deduplicate roots and reject extra paths that are missing, are not directories, or resolve through symlinks. Path comparison accepts Windows casing and separator differences. Each execution checks the paths again. See the [mode definitions](../../../docs/subsystems/sandbox.md).
diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md
index a32213762f..f4b208450e 100644
--- a/packages/sandbox/sandbox/README.zh.md
+++ b/packages/sandbox/sandbox/README.zh.md
@@ -100,7 +100,7 @@ kind: "package-reference"
### 可写根目录
-`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,跳过不存在或不是目录的额外路径,并拒绝经过符号链接解析的目录。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
+`workspaceWritableRoots` 为 bwrap 和 Landlock 推导工作区与显式额外目录。`writableRoots` 为 Seatbelt 和进程内文件系统围栏加入主机临时区域。两者都对根目录去重,并拒绝不存在、不是目录或经过符号链接解析的额外路径。路径比较接受 Windows 上的大小写和分隔符差异。每次执行都会重新检查这些路径。参见[模式定义](../../../docs/subsystems/sandbox.zh.md)。
diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts
index d8b6d99b80..0fa3183987 100644
--- a/packages/sandbox/sandbox/src/index.ts
+++ b/packages/sandbox/sandbox/src/index.ts
@@ -44,8 +44,8 @@ export interface SandboxExecutionPolicy {
workspaceRoot: string
/**
* Extra physical absolute directories `workspace-write` may also write under.
- * Absence means none. The enforcing host skips missing and non-directory entries
- * and rejects symlinked directories on each call. Remote paths are checked on the remote host.
+ * Absence means none. The enforcing host rejects missing, non-directory, and
+ * symlinked entries on each call. Remote paths are checked on the remote host.
* These grant nothing under `read-only` or `danger-full-access`.
*/
additionalWritableRoots?: readonly string[]
diff --git a/packages/sandbox/sandbox/src/roots.ts b/packages/sandbox/sandbox/src/roots.ts
index 276ef5f123..c0d908de1a 100644
--- a/packages/sandbox/sandbox/src/roots.ts
+++ b/packages/sandbox/sandbox/src/roots.ts
@@ -15,7 +15,7 @@
import { realpathSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
-import { resolve } from 'node:path'
+import { relative } from 'node:path'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { SandboxExecutionPolicy } from './index.ts'
@@ -27,20 +27,18 @@ export class SandboxInvalidRootError extends HarnessError {
}
}
-/** Resolve existing directories without allowing mutable aliases to redirect grants. */
-function additionalRoot(path: string): string | undefined {
+/** Resolve existing directories, rejecting aliases that differ under the host's path comparison. */
+function additionalRoot(path: string): string {
let physical: string
let directory: boolean
try {
physical = realpathSync.native(path)
directory = statSync(physical).isDirectory()
} catch (error) {
- const code = (error as NodeJS.ErrnoException).code
- if (code === 'ENOENT' || code === 'ENOTDIR') return undefined
throw new SandboxInvalidRootError(path, `cannot read the directory: ${String(error)}`)
}
- if (!directory) return undefined
- if (physical !== resolve(path)) {
+ if (!directory) throw new SandboxInvalidRootError(path, 'expected an existing directory')
+ if (relative(path, physical) !== '') {
throw new SandboxInvalidRootError(path, `use the physical directory path ${JSON.stringify(physical)}; symlinked grants are not supported`)
}
return physical
@@ -71,7 +69,7 @@ export function canonicalPath(path: string): string {
/**
* The workspace-owned writable roots — the policy's workspace root plus its
- * existing extra directories, as a canonical, deduplicated allow-list. Both
+ * explicit extra directories, as a canonical, deduplicated allow-list. Both
* the workspace and every extra root must be granted together by every
* backend; this is the one derivation of that set, kept separate from the
* platform temp policy each backend documents for itself (Seatbelt and the fs
@@ -79,11 +77,11 @@ export function canonicalPath(path: string): string {
* grants).
* @param policy - the file-effect policy to derive the allow-list from.
* @returns the canonical workspace-owned roots; empty under `read-only`.
- * @throws {SandboxInvalidRootError} An extra root cannot be inspected or uses a symlink; missing and non-directory entries are skipped.
+ * @throws {SandboxInvalidRootError} An extra root cannot be inspected, is not a directory, or uses a symlink.
*/
export function workspaceWritableRoots(policy: SandboxExecutionPolicy): string[] {
if (policy.mode !== 'workspace-write') return []
- const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot).filter(root => root !== undefined)
+ const additional = (policy.additionalWritableRoots ?? []).map(additionalRoot)
return [...new Set([canonicalPath(policy.workspaceRoot), ...additional])]
}
diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts
index 8f8864f57e..27e2a0ae40 100644
--- a/packages/sandbox/sandbox/tests/roots.spec.ts
+++ b/packages/sandbox/sandbox/tests/roots.spec.ts
@@ -52,19 +52,35 @@ describe('workspaceWritableRoots', () => {
.toThrow('use the physical directory path')
})
- it('skips non-directory extra roots and checks them again on each call', () => {
+ it.skipIf(process.platform !== 'win32')('accepts Windows casing and separator differences and retains the canonical directory identity', () => {
+ const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-')))
+ roots.push(ws)
+ const extra = join(ws, 'Memory')
+ mkdirSync(extra)
+ const alias = join(ws, 'memory').replaceAll('\\', '/')
+ const physical = realpathSync.native(extra)
+ expect(alias).not.toBe(physical)
+ const grants = workspaceWritableRoots({
+ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [alias, extra],
+ })
+ expect(grants).toEqual([ws, physical])
+ })
+
+ it('rejects invalid extra roots and checks them again on each call', () => {
const ws = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-roots-')))
roots.push(ws)
const file = join(ws, 'file.txt')
const extra = join(ws, 'extra')
writeFileSync(file, 'file')
- const policy = { mode: 'workspace-write' as const, workspaceRoot: ws,
- additionalWritableRoots: [file, join(file, 'child'), extra] }
- expect(workspaceWritableRoots(policy)).toEqual([ws])
+ for (const path of [file, join(file, 'child'), extra]) {
+ expect(() => workspaceWritableRoots({ mode: 'workspace-write', workspaceRoot: ws, additionalWritableRoots: [path] }))
+ .toThrow('Invalid additional writable directory')
+ }
+ const policy = { mode: 'workspace-write' as const, workspaceRoot: ws, additionalWritableRoots: [extra] }
mkdirSync(extra)
expect(workspaceWritableRoots(policy)).toEqual([ws, extra])
rmSync(extra, { recursive: true })
- expect(workspaceWritableRoots(policy)).toEqual([ws])
+ expect(() => workspaceWritableRoots(policy)).toThrow('cannot read the directory')
})
it('reports inspection failures that do not establish a missing or non-directory path', () => {
diff --git a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
index 96087efe88..e4d21d0d8b 100644
--- a/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
+++ b/packages/test-support/session-snapshot/tests/fixtures/session-write-grant.ts
@@ -16,11 +16,10 @@ export const inject = ['sandboxPolicy']
export function apply(ctx: Context): void {
ctx.on('agent/created', ({ agent }) => {
const extra = join(process.cwd(), 'extra')
- const disposers = [extra, join(extra, 'missing'), join(extra, 'keep.txt')]
- .map(directory => ctx.sandboxPolicy.registerWritableRoot(agent.session, directory))
+ const dispose = ctx.sandboxPolicy.registerWritableRoot(agent.session, extra)
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'tool/result') {
- for (const dispose of disposers) dispose()
+ dispose()
}
})
})
From 27fccbccbc78887bf03266bd547f4ee501262ee9 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 4 Nov 2026 14:32:21 +0800
Subject: [PATCH 020/106] test(ptc): align new retained-mode snapshots with
launch policy
---
snapshots/session/ptc-reset/tool-schemas.expected.json | 2 +-
.../session/ptc-timeout-fallback/tool-schemas.expected.json | 2 +-
snapshots/session/ptc-timeout/tool-schemas.expected.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/snapshots/session/ptc-reset/tool-schemas.expected.json b/snapshots/session/ptc-reset/tool-schemas.expected.json
index dcab3a85ff..c3e65ba191 100644
--- a/snapshots/session/ptc-reset/tool-schemas.expected.json
+++ b/snapshots/session/ptc-reset/tool-schemas.expected.json
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
- "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
+ "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
"parameters": {
"type": "object",
"properties": {
diff --git a/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json b/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json
index 1756617b78..fdaea15490 100644
--- a/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json
+++ b/snapshots/session/ptc-timeout-fallback/tool-schemas.expected.json
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
- "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
+ "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
"parameters": {
"type": "object",
"properties": {
diff --git a/snapshots/session/ptc-timeout/tool-schemas.expected.json b/snapshots/session/ptc-timeout/tool-schemas.expected.json
index 1756617b78..fdaea15490 100644
--- a/snapshots/session/ptc-timeout/tool-schemas.expected.json
+++ b/snapshots/session/ptc-timeout/tool-schemas.expected.json
@@ -2,7 +2,7 @@
"initial": [
{
"name": "run_code",
- "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows this execution's sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
+ "description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. The program contains TypeScript script statements; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level declarations and await are supported; top-level return is not supported. Emit output with console.log(...) or a final lossless JSON expression. The final expression is awaited if it is a Promise. Only printed values and that final expression become program output. Variables, functions, and imported modules remain in memory for this Agent across calls. Use await import(...) for modules. Relative paths use the supplied working directory. Direct filesystem access follows the environment's launch-time sandbox policy; process.env starts empty. Await asynchronous work: an uncaught callback exception or unhandled rejection terminates the process and clears its variables. Values from bindings and imports can belong to another realm; use Array.isArray for arrays and the supplied binding error classes for failures, because instanceof Array or Error may be false. Timeout and cancellation results say whether retained variables were preserved or cleared. Restart, sandbox changes, output overflow, or process failure clears them. Recreate missing variables and resources; conversation history does not restore state. Earlier mutations and tool effects are not rolled back. Approved sandbox escalations use a fresh environment and discard its variables afterward. Do not redeclare existing let, const, or class names; reuse mutable bindings or choose new names. Await tools and cleanup before returning; unfinished tools reject. After completion, timeout, or cancellation, new tool calls are rejected and captured output is discarded; background work may still change state. Await child-process completion before returning; raw output belongs to the active evaluation. If a native top-level let, const, or class initializer throws, its name may remain permanently uninitialized; use a new name or restart the environment. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.",
"parameters": {
"type": "object",
"properties": {
From 61d78c727f836a23a585edbb3d018fa36fa56aa6 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 4 Nov 2026 15:14:30 +0800
Subject: [PATCH 021/106] test(sandbox): refresh revoked-grant context with
agent names
---
snapshots/session/session-sandbox-root/session.v4.jsonl | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/snapshots/session/session-sandbox-root/session.v4.jsonl b/snapshots/session/session-sandbox-root/session.v4.jsonl
index c003c74d67..26571dd426 100644
--- a/snapshots/session/session-sandbox-root/session.v4.jsonl
+++ b/snapshots/session/session-sandbox-root/session.v4.jsonl
@@ -20,7 +20,7 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_session_root"},"toolCallId":"call_session_root","content":[{"type":"text","text":"{{cwd}}/session-root.txt \nfile \n\nCreated file\n "}],"isError":false,"id":"{{message:5}}"},"meta":{"operation":"create","path":"{{cwd}}/session-root.txt","diffs":[]}},"sourceEventSeqs":[17],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your name is \"lead\".\n\nTo message other agents, use send_message({ agent: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their names."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1793015794554,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1793015794554,"index":0,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1793015794554,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1793015794554,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
From b2e19025b7a41f03ac8db3e5b31f76b3ad4e50d5 Mon Sep 17 00:00:00 2001
From: winewill
Date: Wed, 4 Nov 2026 15:17:20 +0800
Subject: [PATCH 022/106] Show installation status during Windows desktop
restarts
---
apps/desktop/installer/locales/de_DE.json | 2 ++
apps/desktop/installer/locales/en_US.json | 2 ++
apps/desktop/installer/locales/es_ES.json | 2 ++
apps/desktop/installer/locales/fr_FR.json | 2 ++
apps/desktop/installer/locales/ja_JP.json | 2 ++
apps/desktop/installer/locales/pt_BR.json | 2 ++
apps/desktop/installer/locales/ru_RU.json | 2 ++
apps/desktop/installer/locales/zh_CN.json | 2 ++
apps/desktop/scripts/installer.nsh | 6 ++++++
apps/desktop/scripts/restart-banner.nsh | 3 +++
10 files changed, 25 insertions(+)
create mode 100644 apps/desktop/scripts/restart-banner.nsh
diff --git a/apps/desktop/installer/locales/de_DE.json b/apps/desktop/installer/locales/de_DE.json
index faf725b88e..4f4cf337d8 100644
--- a/apps/desktop/installer/locales/de_DE.json
+++ b/apps/desktop/installer/locales/de_DE.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "GERMAN",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "Update wird installiert…",
+ "INSTALLER_UPDATE_REOPEN": "Die App startet danach neu.",
"INSTALLER_INSTALL": "Jetzt installieren",
"INSTALLER_FINISH": "Fertigstellen",
"INSTALLER_NATIVE_PROGRESS": "Installation wird vorbereitet… %d%%",
diff --git a/apps/desktop/installer/locales/en_US.json b/apps/desktop/installer/locales/en_US.json
index f522ad9b6d..ff66b2a61a 100644
--- a/apps/desktop/installer/locales/en_US.json
+++ b/apps/desktop/installer/locales/en_US.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "ENGLISH",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "Installing update…",
+ "INSTALLER_UPDATE_REOPEN": "The app will reopen automatically.",
"INSTALLER_INSTALL": "Install now",
"INSTALLER_FINISH": "Finish",
"INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%",
diff --git a/apps/desktop/installer/locales/es_ES.json b/apps/desktop/installer/locales/es_ES.json
index d4fbfd972f..a435101736 100644
--- a/apps/desktop/installer/locales/es_ES.json
+++ b/apps/desktop/installer/locales/es_ES.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "SPANISHINTERNATIONAL",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "Instalando actualización…",
+ "INSTALLER_UPDATE_REOPEN": "La aplicación se abrirá sola.",
"INSTALLER_INSTALL": "Instalar ahora",
"INSTALLER_FINISH": "Finalizar",
"INSTALLER_NATIVE_PROGRESS": "Preparando la instalación… %d%%",
diff --git a/apps/desktop/installer/locales/fr_FR.json b/apps/desktop/installer/locales/fr_FR.json
index 326ead62a8..17b14b680d 100644
--- a/apps/desktop/installer/locales/fr_FR.json
+++ b/apps/desktop/installer/locales/fr_FR.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "FRENCH",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "Installation en cours…",
+ "INSTALLER_UPDATE_REOPEN": "Redémarrage automatique.",
"INSTALLER_INSTALL": "Installer maintenant",
"INSTALLER_FINISH": "Terminer",
"INSTALLER_NATIVE_PROGRESS": "Préparation de l'installation… %d%%",
diff --git a/apps/desktop/installer/locales/ja_JP.json b/apps/desktop/installer/locales/ja_JP.json
index ee614789c5..afddce9f17 100644
--- a/apps/desktop/installer/locales/ja_JP.json
+++ b/apps/desktop/installer/locales/ja_JP.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "JAPANESE",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "アップデートを適用中…",
+ "INSTALLER_UPDATE_REOPEN": "完了後、自動的に起動します。",
"INSTALLER_INSTALL": "今すぐインストール",
"INSTALLER_FINISH": "完了",
"INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%",
diff --git a/apps/desktop/installer/locales/pt_BR.json b/apps/desktop/installer/locales/pt_BR.json
index dda249e46f..a1728036dc 100644
--- a/apps/desktop/installer/locales/pt_BR.json
+++ b/apps/desktop/installer/locales/pt_BR.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "PORTUGUESEBR",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "Instalando atualização…",
+ "INSTALLER_UPDATE_REOPEN": "O aplicativo reabrirá sozinho.",
"INSTALLER_INSTALL": "Instale agora",
"INSTALLER_FINISH": "Concluir",
"INSTALLER_NATIVE_PROGRESS": "Preparando a instalação… %d%%",
diff --git a/apps/desktop/installer/locales/ru_RU.json b/apps/desktop/installer/locales/ru_RU.json
index e5fec3f8d1..1f3d39660c 100644
--- a/apps/desktop/installer/locales/ru_RU.json
+++ b/apps/desktop/installer/locales/ru_RU.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "RUSSIAN",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "Установка обновления…",
+ "INSTALLER_UPDATE_REOPEN": "Приложение откроется само.",
"INSTALLER_INSTALL": "Установить сейчас",
"INSTALLER_FINISH": "Готово",
"INSTALLER_NATIVE_PROGRESS": "Подготовка установки… %d%%",
diff --git a/apps/desktop/installer/locales/zh_CN.json b/apps/desktop/installer/locales/zh_CN.json
index e3f40f13be..b943a77baf 100644
--- a/apps/desktop/installer/locales/zh_CN.json
+++ b/apps/desktop/installer/locales/zh_CN.json
@@ -1,6 +1,8 @@
{
"nsisLanguage": "SIMPCHINESE",
"strings": {
+ "INSTALLER_UPDATE_WAIT": "正在安装更新,请稍候。",
+ "INSTALLER_UPDATE_REOPEN": "完成后将自动打开应用。",
"INSTALLER_INSTALL": "立即安装",
"INSTALLER_FINISH": "完成",
"INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%",
diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh
index 43c6a4c7d7..5ac72336da 100644
--- a/apps/desktop/scripts/installer.nsh
+++ b/apps/desktop/scripts/installer.nsh
@@ -1,4 +1,5 @@
!include "LogicLib.nsh"
+!include "${__FILEDIR__}\restart-banner.nsh"
!include "FileFunc.nsh"
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
@@ -66,6 +67,11 @@ ManifestDPIAware true
File "/oname=$PLUGINSDIR\brand-dark.bmp" "${INSTALLER_BUILD_DIR}\brand-dark.bmp"
File "/oname=$PLUGINSDIR\brand-dark-2x.bmp" "${INSTALLER_BUILD_DIR}\brand-dark-2x.bmp"
File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
+ ${If} ${Silent}
+ ${AndIf} ${isUpdated}
+ ${AndIf} ${isForceRun}
+ !insertmacro ShowRestartBanner
+ ${EndIf}
Call InstallerCheckAppRunning
${If} ${Silent}
Call InstallerPreflight
diff --git a/apps/desktop/scripts/restart-banner.nsh b/apps/desktop/scripts/restart-banner.nsh
new file mode 100644
index 0000000000..8142e1c896
--- /dev/null
+++ b/apps/desktop/scripts/restart-banner.nsh
@@ -0,0 +1,3 @@
+!macro ShowRestartBanner
+ Banner::show /NOUNLOAD /set 76 "$(INSTALLER_UPDATE_WAIT)" /set 1030 "$(INSTALLER_UPDATE_REOPEN)" "${PRODUCT_NAME}"
+!macroend
From 492448e75499a35262efd86d8436f671597e7cd4 Mon Sep 17 00:00:00 2001
From: winewill
Date: Wed, 4 Nov 2026 15:21:28 +0800
Subject: [PATCH 023/106] Use the Windows installer icon for restart status and
shorten its Chinese copy
---
apps/desktop/installer/locales/zh_CN.json | 4 ++--
apps/desktop/scripts/restart-banner.nsh | 19 +++++++++++++++++++
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/installer/locales/zh_CN.json b/apps/desktop/installer/locales/zh_CN.json
index b943a77baf..7df49dc940 100644
--- a/apps/desktop/installer/locales/zh_CN.json
+++ b/apps/desktop/installer/locales/zh_CN.json
@@ -1,8 +1,8 @@
{
"nsisLanguage": "SIMPCHINESE",
"strings": {
- "INSTALLER_UPDATE_WAIT": "正在安装更新,请稍候。",
- "INSTALLER_UPDATE_REOPEN": "完成后将自动打开应用。",
+ "INSTALLER_UPDATE_WAIT": "正在安装更新…",
+ "INSTALLER_UPDATE_REOPEN": "更新完成后将自动重启应用。",
"INSTALLER_INSTALL": "立即安装",
"INSTALLER_FINISH": "完成",
"INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%",
diff --git a/apps/desktop/scripts/restart-banner.nsh b/apps/desktop/scripts/restart-banner.nsh
index 8142e1c896..04d646a0ae 100644
--- a/apps/desktop/scripts/restart-banner.nsh
+++ b/apps/desktop/scripts/restart-banner.nsh
@@ -1,3 +1,22 @@
!macro ShowRestartBanner
Banner::show /NOUNLOAD /set 76 "$(INSTALLER_UPDATE_WAIT)" /set 1030 "$(INSTALLER_UPDATE_REOPEN)" "${PRODUCT_NAME}"
+ Push $0
+ Push $1
+ Push $2
+ Push $3
+ Banner::getWindow /NOUNLOAD
+ Pop $0
+ ; The shell and the dialog image use different icon sizes from the same installer.
+ System::Call 'shell32::ExtractIconExW(w "$EXEPATH", i 0, *p .r1, *p .r2, i 1) i.r3'
+ ${If} $1 != 0
+ ${AndIf} $2 != 0
+ SendMessage $0 ${WM_SETICON} 1 $1
+ SendMessage $0 ${WM_SETICON} 0 $2
+ GetDlgItem $3 $0 -1
+ SendMessage $3 0x170 $1 0
+ ${EndIf}
+ Pop $3
+ Pop $2
+ Pop $1
+ Pop $0
!macroend
From 69e6199cb7219344a3667426134dc66f2e604081 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 4 Nov 2026 16:06:17 +0800
Subject: [PATCH 024/106] test(sandbox): canonicalize roots allocated under
home
---
packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts | 4 ++--
.../ptc-runtime-node/tests/persistent-sandbox.spec.ts | 3 ++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
index 1134b27cfb..fa9b48d873 100644
--- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
+++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
@@ -10,7 +10,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
-import { existsSync } from 'node:fs'
+import { existsSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, parse } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
@@ -42,7 +42,7 @@ beforeEach(async ({ onTestFinished }) => {
// Both siblings must be outside automatic temp grants for containment denials to be meaningful.
const directory = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-fssbx-'))
onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) })
- base = directory
+ base = realpathSync.native(directory)
assertWorkspaceOutsideTemp(base)
workspace = join(base, 'ws')
outside = join(base, 'out')
diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
index 62c4e1896a..8461a3152c 100644
--- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
+++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-sandbox.spec.ts
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import { realpathSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, rm } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
@@ -11,7 +12,7 @@ import { mountRuntime } from './setup.ts'
import type { Config } from '../src/index.ts'
async function setup(mode: 'read-only' | 'workspace-write' = 'read-only', executionMode: NonNullable = 'persistent-in-memory') {
- const root = await mkdtemp(join(homedir(), '.dsh-persistent-sandbox-'))
+ const root = realpathSync.native(await mkdtemp(join(homedir(), '.dsh-persistent-sandbox-')))
const cwd = join(root, 'workspace')
await mkdir(cwd)
const ctx = new Context()
From a63ba54f306fc30fcaa7e4b6f73a1f7e0b59ccaa Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Tue, 27 Oct 2026 16:23:55 +0800
Subject: [PATCH 025/106] feat(sandbox): support writable-directory sets on
Windows
---
...ssion-writable-directory-effects.i18n.yaml | 8 +-
...0-26-session-writable-directory-effects.md | 4 +-
...6-session-writable-directory-effects.zh.md | 4 +-
...ows-acl-restricted-token-sandbox.i18n.yaml | 8 +-
...08-windows-acl-restricted-token-sandbox.md | 4 +-
...windows-acl-restricted-token-sandbox.zh.md | 4 +-
docs/config-catalog.md | 2 +-
docs/config-catalog.zh.md | 2 +-
docs/subsystems/sandbox.md | 2 +-
docs/subsystems/sandbox.zh.md | 2 +-
.../sandbox/sandbox-local/README.i18n.yaml | 8 +-
packages/sandbox/sandbox-local/README.md | 4 +-
packages/sandbox/sandbox-local/README.zh.md | 4 +-
packages/sandbox/sandbox-local/src/index.ts | 119 ++++++++---------
.../sandbox-local/tests/acl-grants.spec.ts | 121 ++++++++++++++++--
.../sandbox/sandbox-local/tests/local.spec.ts | 7 +-
.../sandbox/sandbox-policy/README.i18n.yaml | 4 +-
packages/sandbox/sandbox-policy/README.md | 2 +-
packages/sandbox/sandbox-policy/README.zh.md | 2 +-
.../sandbox-windows-acl/README.i18n.yaml | 20 +--
.../sandbox/sandbox-windows-acl/README.md | 12 +-
.../sandbox/sandbox-windows-acl/README.zh.md | 12 +-
.../sandbox/sandbox-windows-acl/src/grant.ts | 10 +-
.../sandbox/sandbox-windows-acl/src/index.ts | 26 ++--
.../sandbox/sandbox-windows-acl/src/runner.ts | 26 ++--
.../sandbox-windows-acl/src/workspace-sid.ts | 39 ++----
.../sandbox-windows-acl/tests/grant.spec.ts | 17 ++-
.../sandbox-windows-acl/tests/runner.spec.ts | 69 ++++++++++
.../tests/workspace-sid.spec.ts | 17 +++
packages/sandbox/sandbox/src/index.ts | 2 +-
30 files changed, 372 insertions(+), 189 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
index bcc2fe33cb..1368af8de4 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
@@ -9,11 +9,11 @@
en: 2a32db1f27371403
zh: 89cf04705ef66769
/agent-note-session-writable-directory-effects/decision:
- en: af67969841bedb67
- zh: edabdfaa30bcadd1
+ en: 504c8d2399c47110
+ zh: 6c30739907a86f07
/agent-note-session-writable-directory-effects/alternatives-considered:
en: dad77744dea17bde
zh: bb3838f9f9099944
/agent-note-session-writable-directory-effects/consequences:
- en: 7edcc1022b9f3ee5
- zh: 61163e25d5a90f16
+ en: 008f7270b7d3aaaf
+ zh: 2b60ed4c4739ff75
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
index 1608b93010..bb3a47036b 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
@@ -16,6 +16,8 @@ Registration disposal affects subsequent operations, not already-resolved calls
Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules.
+Windows standing ACL grants use one deterministic SID per complete sorted, deduplicated canonical directory set. Each new execution resolves or creates that exact set's grant entry; changing the set never broadens the SID of a process already running. Reusing only the workspace identity would accumulate extra-directory authority across Sessions and after disposal. JSON encoding preserves directory boundaries in the hash input; singleton sets retain their existing SID. Standing ACEs remain reusable, while private temp grants keep their separate per-session/workspace identity and lifetime.
+
## Alternatives considered
**Shared mutable extra roots.** Enabling memory in one Session would also authorize unrelated Sessions using the same service.
@@ -26,4 +28,4 @@ Missing, non-directory, and symlinked extra paths stop execution with `SandboxIn
## Consequences
-Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Windows subprocess confinement retains its existing unsupported-extra-roots error. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
+Independent registrations for the same directory survive another owner's disposal. Removing a registration cannot revoke overlapping configured or workspace access. A running process may retain a removed grant, or lack a newly added grant; a new process uses the current policy. Focused coverage checks Session isolation, duplicate ownership, plugin unload, filesystem denial and mutation, request context, and retained PTC launch permissions.
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
index 3cc4f01c3f..9488ee695a 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
@@ -16,6 +16,8 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。
+Windows 常驻 ACL 授权为完整、排序去重后的规范目录集合使用一个确定性 SID。每次新执行查找或创建完全匹配该集合的授权项;集合变化不会扩大已运行进程的 SID 权限。如果仅复用工作区身份,额外目录权限就会在不同 Session 之间及注册清理后累积。JSON 编码在哈希输入中保留目录边界;单目录集合保留现有 SID。常驻 ACE 可继续复用,私有临时授权则保持独立的每会话/工作区身份与生命周期。
+
## 考虑过的替代方案
**共享的可变额外根目录。** 在一个 Session 中启用记忆,也会授权使用同一服务的无关 Session。
@@ -26,4 +28,4 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
## 影响
-同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。Windows 子进程隔离保留现有的不支持额外根目录错误。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
+同一目录的独立注册不会随另一个所有者的清理而移除。移除注册无法撤销重叠的配置授权或工作区访问权限。运行中的进程可能保留已移除的授权,或缺少新添加的授权;新进程使用当前策略。针对性覆盖检查 Session 隔离、重复注册的所有权、插件卸载、文件系统拒绝与修改、请求上下文,以及保留 PTC 环境的启动权限。
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
index cd33be628c..87816ade93 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
@@ -9,8 +9,8 @@
en: f62d87b7623a9904
zh: 48bbb7c35298f96c
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
- en: 10e9cfb7972cff03
- zh: 8406ba64d72a1fcc
+ en: 7c8a053085200aee
+ zh: dd36659227430d93
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
@@ -30,8 +30,8 @@
en: e2c573d90c4ddeb9
zh: f0259213e1321722
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences:
- en: dd43744417f207a5
- zh: d7b4fee1e2d87b60
+ en: f14d0c8fb77d57ab
+ zh: 1842f13d91de8bd2
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing:
en: b223cb2cf13193c9
zh: 61ddd0a0b99f8f56
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
index 6cda3f1970..acb525290b 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
@@ -10,7 +10,7 @@ The original [historical sandbox decision](../../archived/feature/2026-07-06-san
## Decision
-Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
+Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The write SID is derived deterministically from the complete canonical writable-directory set (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing ACEs are the reuse cache for that exact set, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
## ACL diagnosis and recovery
@@ -40,7 +40,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
## Consequences
-Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
+Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory set per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
## Testing
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
index 1f2848d31c..70a705f30f 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## 决策
-直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
+直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。写入 SID 由完整的规范可写目录集合确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 是完全相同集合的复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
## ACL 诊断与恢复
@@ -41,7 +41,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
## 后果
-所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
+所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录集合只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
## 测试
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 2d91981949..dcd5781374 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2926,7 +2926,7 @@ export interface Config {
## `@deepseek-ai/dsh-sandbox-local`
-- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts)
+- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts)
```ts config-catalog
/** Plugin config. All optional — `static Config` supplies the defaults. */
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index b586ca6aa2..428de8d3aa 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -2928,7 +2928,7 @@ export interface Config {
## `@deepseek-ai/dsh-sandbox-local`
-- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts)
+- `source`: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts)
```ts config-catalog
/** Plugin config. All optional — `static Config` supplies the defaults. */
diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md
index a3539c85c2..8b19c57010 100644
--- a/docs/subsystems/sandbox.md
+++ b/docs/subsystems/sandbox.md
@@ -64,7 +64,7 @@ interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
- * while the workspace SID and standing grant remain per-workspace); absent
+ * while standing grants are keyed by the complete writable-directory set); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md
index 78b8b63113..8c0df187c3 100644
--- a/docs/subsystems/sandbox.zh.md
+++ b/docs/subsystems/sandbox.zh.md
@@ -64,7 +64,7 @@ interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
- * while the workspace SID and standing grant remain per-workspace); absent
+ * while standing grants are keyed by the complete writable-directory set); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml
index 56f69b41d7..8f842f5e52 100644
--- a/packages/sandbox/sandbox-local/README.i18n.yaml
+++ b/packages/sandbox/sandbox-local/README.i18n.yaml
@@ -36,8 +36,8 @@
en: f0c44248c2ca6acc
zh: 554e9f706671d222
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles:
- en: 008967ee30fbbe46
- zh: 9e5c6a3e97851a69
+ en: bd441e16c283095e
+ zh: eef437f45922e174
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects:
en: 62adfc9229c656cc
zh: 11b1534b6d61e5de
@@ -54,8 +54,8 @@
en: e290ccc9b3c92c08
zh: 469589ac12cdadab
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work:
- en: 38756b82fe0cd48c
- zh: 441cc45ebe5b51fd
+ en: 71f863f5ee97d9ba
+ zh: cabc01953e6cb94d
/deepseek-ai-dsh-sandbox-local/known-limitations-and-deferred-work/dev-note:
en: cc273320f6a1beaf
zh: 5c57a312ddc50651
diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md
index dfe65511b6..5fd8d86984 100644
--- a/packages/sandbox/sandbox-local/README.md
+++ b/packages/sandbox/sandbox-local/README.md
@@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`).
-The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
+The Windows rung derives a deterministic write SID from each sorted, deduplicated set of canonical writable directories and reuses standing ACEs only for that exact set, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources.
@@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
-- **Windows extra roots** — Windows ACL rejects workspace-write subprocesses with existing writable directories beyond the workspace with `SANDBOX_UNSUPPORTED_POLICY`, before grants or child creation. Filesystem tools can enforce these grants independently. Remove the extra grants or use a supported host for process operations.
+- **Windows temp separation** — writable roots cannot contain the ambient temp root or overlap the session's private temp directory. Those overlaps would expose private temp files through a shared directory capability.
- **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full.
- **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.
diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md
index 152110d5f6..b20edc2aaf 100644
--- a/packages/sandbox/sandbox-local/README.zh.md
+++ b/packages/sandbox/sandbox-local/README.zh.md
@@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名
Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。
-Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
+Windows 档从排序、去重后的规范可写目录集合派生确定性写入 SID,并仅为完全相同的集合复用常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。
@@ -130,7 +130,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同
这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。
-- **Windows 额外根目录**——Windows ACL 在授权或创建子进程前,以 `SANDBOX_UNSUPPORTED_POLICY` 拒绝授权工作区以外现存目录的 workspace-write 子进程。文件系统工具可独立执行这些授权。运行进程操作前,请移除额外授权或使用支持此功能的主机。
+- **Windows 临时目录隔离**——可写根目录不能包含系统临时根目录,也不能与会话的私有临时目录重叠。这类重叠会通过共享目录能力暴露私有临时文件。
- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。
- **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
- **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts
index 5f46c01b51..b9eeb61946 100644
--- a/packages/sandbox/sandbox-local/src/index.ts
+++ b/packages/sandbox/sandbox-local/src/index.ts
@@ -5,19 +5,13 @@
* classification facts. Missing or unusable confinement fails closed rather
* than returning the original argv.
*
- * The windows-acl rung additionally owns the write grants: the write SID is
- * the per-WORKSPACE identity derived from the canonical workspace path
- * (`workspaceWriteSid`), while every live session receives a RANDOM private
- * temp directory and its own derived capability (`tempWriteSid`). The
- * workspace-root ACE materializes once per workspace per server lifetime
- * and STANDS (the cross-session reuse cache — the exact-ACE skip makes
- * every later provision O(1) instead of re-propagating the tree per
- * session); the private-temp ACEs are revoked on dispose. The runner
- * receives both SIDs (their presence marks the seam-managed contract) and
- * stops managing DACLs itself. The rung reports partial enforcement because
- * NTFS hard links alias one file object across paths, reads stay unconfined,
- * and a tree another AppContainer tool has ACL'd with a package SID is not
- * readable by the Low-integrity child.
+ * The windows-acl rung owns standing grants keyed by the complete canonical
+ * writable-directory set. Each set has a deterministic write SID; executions
+ * with different sets never share that capability. Every live session/workspace
+ * pair has a separate random private temp directory and revocable temp SID.
+ * The runner receives both identities and does not manage their DACLs.
+ * Enforcement remains partial because hard links alias file objects, reads
+ * stay unconfined, and AppContainer ACLs can deny reads to the child.
* @module @deepseek-ai/dsh-sandbox-local
*/
@@ -34,10 +28,10 @@ import {
} from '@deepseek-ai/node-addon-system/landlock-run'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import { SandboxProvider, SandboxUnavailableError, SandboxUnsupportedPolicyError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
+import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
-import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
+import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
@@ -244,7 +238,7 @@ const RUNNER_FAILURE_RULES = {
/**
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
* chain verdict and, on the windows-acl rung, the write grants
- * ({@link AclWriteGrant}: the standing workspace-root grant per workspace
+ * ({@link AclWriteGrant}: standing grants per writable-directory set
* and the revocable private-temp grant per live session/workspace pair, the
* latter revoked on provider dispose); the one-time probes spawn nothing
* else.
@@ -265,14 +259,9 @@ export class LocalSandboxProvider extends SandboxProvider {
private readonly probeTimeoutMs: number
/** Cached chain verdict; undefined until the first confined wrap needs it. */
private selectedRunner: SelectedRunner | 'unavailable' | undefined
- /**
- * Server-lifetime write grants (windows-acl rung): the STANDING
- * workspace-root grant per workspace (its ACE is the cross-session reuse
- * cache and outlives the provider — never revoked) and the REVOCABLE
- * private-temp grant per live session/workspace pair (revoked on provider
- * dispose).
- */
- private readonly workspaceGrants = new Map()
+ /** Standing grants keyed by the sorted canonical directory set; ACEs outlive the provider. */
+ private readonly directoryGrants = new Map()
+ /** Revocable private temp grants per live session/workspace pair. */
private readonly tempCapabilities = new Map()
constructor(ctx: Context, config: Config) {
@@ -355,21 +344,15 @@ export class LocalSandboxProvider extends SandboxProvider {
}
/**
- * The windows-acl runner argv for one policy. With a calling session (the
- * policy's `sessionId`) under workspace-write, the grants are materialized
- * once per provider lifetime — the standing workspace-root grant per
- * workspace and a revocable, RANDOM private-temp capability per live
- * session/workspace pair. The runner receives `--write-sid` plus
- * `--temp-write-sid` and grants nothing itself. Agentless workspace-write
- * calls pass the ambient temp ROOT and no SID flags: the runner creates and
- * removes a random private child directory for that one invocation.
+ * Build the Windows runner invocation for the current writable-directory set.
+ * Session calls reuse standing directory grants and a private temp capability.
+ * Agentless calls let the runner materialize the set and own its private temp.
* @param policy - the resolved per-call policy.
* @returns the runner invocation.
*/
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
- if (workspaceWritableRoots(policy).length > 1) {
- throw new SandboxUnsupportedPolicyError('Windows ACL confinement does not support additional writable directories; remove the extra grants or use a supported host')
- }
+ const roots = workspaceWritableRoots(policy)
+ const extraArgs = roots.slice(1).flatMap(root => ['--writable-root', root])
const sessionId = policy.sessionId
if (sessionId === undefined || policy.mode === 'read-only') {
return [
@@ -377,54 +360,54 @@ export class LocalSandboxProvider extends SandboxProvider {
'--workspace', policy.workspaceRoot,
'--temp', tmpdir(),
'--mode', policy.mode,
+ ...extraArgs,
]
}
- const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot)
+ const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot, roots)
return [
...this.windowsAclRunnerInvocation(),
'--workspace', policy.workspaceRoot,
'--temp', temp.dir,
'--mode', policy.mode,
- '--write-sid', workspaceWriteSid(policy.workspaceRoot),
+ ...extraArgs,
+ '--write-sid', workspaceWriteSid(policy.workspaceRoot, roots),
'--temp-write-sid', temp.writeSid,
]
}
/**
- * Materialize one workspace-write policy's ACEs once per provider
- * lifetime. The workspace SID and standing root grant are shared by the
- * workspace. The temp directory is random and carries a distinct SID, so
- * another session on the same workspace cannot use the shared workspace
- * SID to enter it. A fresh provider always chooses a new path; crash
- * residue therefore cannot collide with or authorize a resumed session.
- * Fail-closed: a half-materialized temp grant is revoked and its directory
- * removed before the error propagates.
+ * Resolve or materialize the exact writable-directory set's standing ACEs.
+ * Private temp capabilities remain scoped to the session/workspace pair;
+ * changing directory sets does not revoke grants held by running processes.
+ * Failed temp materialization revokes its grant and removes its directory.
* @param sessionId - the policy's calling-session identity.
* @param workspaceRoot - the resolved policy root.
+ * @param roots - the complete canonical writable-directory set for this execution.
* @returns the pair's private temp directory and write capability.
*/
- private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
- assertTempRootOutsideWorkspace(workspaceRoot, tmpdir())
- const writeSid = workspaceWriteSid(workspaceRoot)
- if (!this.workspaceGrants.has(workspaceRoot)) {
- const grant = AclWriteGrant.create(writeSid)
- try {
- grant.add(workspaceRoot, true)
- } catch (error) {
- // Free the SID; a standing ACE (if the apply succeeded before a
- // post-apply throw) is the intended end state, not an error
- // artifact — nothing to revoke.
- try {
- grant.dispose()
- } catch (cleanupError) {
- throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed')
- }
- throw error
- }
- this.workspaceGrants.set(workspaceRoot, grant)
- }
+ private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, roots: readonly string[]): AclTempCapability {
+ for (const root of roots) assertTempRootOutsideWorkspace(root, tmpdir())
const key = JSON.stringify([String(sessionId), workspaceRoot])
const existing = this.tempCapabilities.get(key)
+ if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir)
+ const writeSid = workspaceWriteSid(workspaceRoot, roots)
+ const grantKey = JSON.stringify([...roots].sort())
+ const cachedGrant = this.directoryGrants.get(grantKey)
+ const directoryGrant = cachedGrant ?? AclWriteGrant.create(writeSid)
+ try {
+ for (const root of roots) directoryGrant.add(root, true)
+ } catch (error) {
+ if (cachedGrant !== undefined) throw error
+ // Standing ACEs remain reusable; only the failed entry's allocations
+ // are released. A later execution retries the complete directory set.
+ try {
+ directoryGrant.dispose()
+ } catch (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed')
+ }
+ throw error
+ }
+ this.directoryGrants.set(grantKey, directoryGrant)
if (existing !== undefined) return existing
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
const tempSid = tempWriteSid(tempDir)
@@ -466,9 +449,9 @@ export class LocalSandboxProvider extends SandboxProvider {
* OS temp hygiene (or manual removal) eventually reclaims it.
*/
private revokeAclGrants(): void {
- if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return
+ if (this.directoryGrants.size === 0 && this.tempCapabilities.size === 0) return
const failures: unknown[] = []
- for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
+ for (const grant of [...this.directoryGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
try {
grant.dispose()
} catch (error) {
@@ -482,7 +465,7 @@ export class LocalSandboxProvider extends SandboxProvider {
failures.push(error)
}
}
- this.workspaceGrants.clear()
+ this.directoryGrants.clear()
this.tempCapabilities.clear()
if (failures.length > 0) {
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
index 646377e85c..63020397cf 100644
--- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
+++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
@@ -5,7 +5,7 @@
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
*/
-import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,18 +13,21 @@ import { Context } from '@deepseek-ai/cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
+import { workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
const mockState = vi.hoisted(() => ({
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
addFailure: undefined as Error | undefined,
+ addFailurePath: undefined as string | undefined,
/** Restrict an add failure to standing (workspace) or revocable (temp). */
addFailureStanding: undefined as boolean | undefined,
createTempFailure: undefined as Error | undefined,
disposeFailure: undefined as Error | undefined,
}))
-vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
+vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', async (importOriginal) => {
+ const actual = await importOriginal()
class MockAclWriteGrant {
readonly writeSid: string
readonly added: Array<{ path: string; standing: boolean }> = []
@@ -40,6 +43,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
add(path: string, standing = false): void {
this.added.push({ path, standing })
if (mockState.addFailure !== undefined
+ && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)
&& (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) {
throw mockState.addFailure
}
@@ -50,6 +54,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
}
}
return {
+ ...actual,
registerAclDiagnosisSkill: vi.fn(),
AclWriteGrant: MockAclWriteGrant,
assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => {
@@ -59,13 +64,10 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`)
}
},
- workspaceWriteSid: () => 'S-1-4-42-42',
tempWriteSid: (path: string) => `TEMP:${path}`,
}
})
-const WORKSPACE_SID = 'S-1-4-42-42'
-
async function setup() {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSandboxProvider, {})
@@ -89,6 +91,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
beforeEach(() => {
mockState.grants = []
mockState.addFailure = undefined
+ mockState.addFailurePath = undefined
mockState.addFailureStanding = undefined
mockState.createTempFailure = undefined
mockState.disposeFailure = undefined
@@ -129,19 +132,19 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
expect(tempDir).toBeDefined()
expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u)
expect(tempSid).toBe(`TEMP:${tempDir}`)
- expect(tempSid).not.toBe(WORKSPACE_SID)
+ expect(tempSid).not.toBe(workspaceWriteSid(ws))
expect(confined.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', ws,
'--temp', tempDir,
'--mode', 'workspace-write',
- '--write-sid', WORKSPACE_SID,
+ '--write-sid', workspaceWriteSid(ws),
'--temp-write-sid', tempSid,
'--',
'pwsh', '/Command', 'x',
])
expect(mockState.grants).toEqual([
- expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }),
+ expect.objectContaining({ writeSid: workspaceWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }),
expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }),
])
expect(existsSync(tempDir ?? '')).toBe(true)
@@ -157,6 +160,104 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
}
})
+ it('selects exact directory-set grants while retaining session-private temp capabilities', async () => {
+ const { sandbox, fiber } = await setup()
+ try {
+ const ws = workspaceRoot()
+ const memory = workspaceRoot()
+ const cache = workspaceRoot()
+ scratch.push(ws, memory, cache)
+ const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') }
+ const base = await sandbox.confine(['true'], policy)
+ const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] })
+ const expandedSid = workspaceWriteSid(ws, [memory, cache])
+ expect(flag(expanded.argv, '--write-sid')).toBe(expandedSid)
+ expect(expandedSid).not.toBe(flag(base.argv, '--write-sid'))
+ expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp'))
+ expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid'))
+ expect(mockState.grants.filter(grant => grant.writeSid === expandedSid)).toEqual([
+ expect.objectContaining({ added: [ws, memory, cache].map(path => ({ path, standing: true })), disposed: false }),
+ ])
+ const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] })
+ expect(flag(reordered.argv, '--write-sid')).toBe(expandedSid)
+ expect(mockState.grants).toHaveLength(3)
+ expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv)
+ expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
+
+ const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') })
+ expect(flag(sibling.argv, '--write-sid')).toBe(flag(base.argv, '--write-sid'))
+ expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid'))
+ expect(mockState.grants).toHaveLength(4)
+ } finally { await fiber.dispose(); cleanup() }
+ })
+
+ it('reapplies a cached set grant when an extra directory is recreated', async () => {
+ const { sandbox, fiber } = await setup()
+ try {
+ const ws = workspaceRoot()
+ const extra = workspaceRoot()
+ scratch.push(ws, extra)
+ const policy: SandboxPolicy = {
+ mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra],
+ }
+ const first = await sandbox.confine(['true'], policy)
+ const directoryGrant = mockState.grants[0]!
+ rmSync(extra, { recursive: true })
+ mkdirSync(extra)
+ const second = await sandbox.confine(['true'], policy)
+ expect(second.argv).toEqual(first.argv)
+ expect(mockState.grants).toHaveLength(2)
+ expect(mockState.grants[0]).toBe(directoryGrant)
+ expect(directoryGrant.added).toEqual([ws, extra, ws, extra].map(path => ({ path, standing: true })))
+ } finally { await fiber.dispose(); cleanup() }
+ })
+
+ it('retries every directory after a partial set grant fails', async () => {
+ const { sandbox, fiber } = await setup()
+ try {
+ const ws = workspaceRoot()
+ const extra = workspaceRoot()
+ scratch.push(ws, extra)
+ const policy: SandboxPolicy = {
+ mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('set-retry'), additionalWritableRoots: [extra],
+ }
+ mockState.addFailure = new Error('extra grant failed')
+ mockState.addFailurePath = extra
+ await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed')
+ expect(mockState.grants).toHaveLength(1)
+ expect(mockState.grants[0]).toMatchObject({
+ added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: true,
+ })
+ mockState.addFailure = undefined
+ await sandbox.confine(['true'], policy)
+ expect(mockState.grants).toHaveLength(3)
+ expect(mockState.grants[1]).toMatchObject({
+ added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: false,
+ })
+ mockState.addFailure = new Error('cached grant refresh failed')
+ await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed')
+ expect(mockState.grants).toHaveLength(3)
+ expect(mockState.grants[1]!.disposed).toBe(false)
+ mockState.addFailure = undefined
+ await sandbox.confine(['true'], policy)
+ expect(mockState.grants).toHaveLength(3)
+ } finally { await fiber.dispose(); cleanup() }
+ })
+
+ it('refuses a changed set overlapping its private temp before adding standing grants', async () => {
+ const { sandbox, fiber } = await setup()
+ try {
+ const ws = workspaceRoot()
+ scratch.push(ws)
+ const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-overlap') }
+ const confined = await sandbox.confine(['true'], policy)
+ const temp = realpathSync.native(flag(confined.argv, '--temp')!)
+ await expect(sandbox.confine(['true'], { ...policy, additionalWritableRoots: [temp] }))
+ .rejects.toThrow('private temp directory must be disjoint from writable directories')
+ expect(mockState.grants).toHaveLength(2)
+ } finally { await fiber.dispose(); cleanup() }
+ })
+
it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => {
try {
const { sandbox, fiber } = await setup()
@@ -176,7 +277,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
expect(mockState.grants).toHaveLength(0)
const upgraded = await sandbox.confine(['true'], workspaceWrite)
- expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID)
+ expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(workspaceWriteSid(ws))
expect(mockState.grants).toHaveLength(2)
await sandbox.confine(['true'], readOnly)
expect(mockState.grants).toHaveLength(2)
@@ -252,7 +353,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
mockState.disposeFailure = new Error('workspace cleanup exploded')
await expect(sandbox.confine(['true'], {
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'),
- })).rejects.toThrow(/workspace grant failed and its cleanup also failed/u)
+ })).rejects.toThrow(/directory grant failed and its cleanup also failed/u)
expect(mockState.grants).toHaveLength(2)
} finally {
cleanup()
diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts
index 36eb46c9c1..928578851e 100644
--- a/packages/sandbox/sandbox-local/tests/local.spec.ts
+++ b/packages/sandbox/sandbox-local/tests/local.spec.ts
@@ -430,7 +430,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
})
describe('the windows-acl probe (runner invocation contract)', () => {
- it('refuses unsupported extra grants before a Windows runner or ACL mutation', async () => {
+ it('passes existing extra directories to the Windows runner and skips non-directories', async () => {
const { ctx, sandbox } = await setup({}, {
chain: ['windows-acl'],
probeWindowsAcl: () => true,
@@ -442,8 +442,9 @@ describe('the windows-acl probe (runner invocation contract)', () => {
writeFileSync(file, 'file')
try {
await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [join(extra, 'missing'), file] })).rejects.toThrow('Invalid additional writable directory')
- await expect(sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] }))
- .rejects.toThrow('Windows ACL confinement does not support additional writable directories')
+ const confined = await sandbox.confine(['true'], { ...WW, additionalWritableRoots: [extra] })
+ expect(confined.argv).toContain('--writable-root')
+ expect(confined.argv[confined.argv.indexOf('--writable-root') + 1]).toBe(extra)
await expect(sandbox.confine(['true'], { ...RO, additionalWritableRoots: ['/extra'] })).resolves.toBeDefined()
} finally {
await ctx.fiber.dispose()
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index 3638e6ac9f..61c94dc100 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -24,8 +24,8 @@
en: c7e924a923bfac72
zh: fdaf930a5800ffa6
/deepseek-ai-dsh-sandbox-policy/use-this-package/registering-a-session-s-writable-directory:
- en: 169b82d5392ec91a
- zh: e0ef9428cb794f9c
+ en: 28587491ad2ac66a
+ zh: c5d4dc441e3f0459
/deepseek-ai-dsh-sandbox-policy/use-this-package/switching-a-session-s-mode:
en: 957d72691e470e5c
zh: c22a3a750af0e4a3
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index 431c140934..1a4e672fdc 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -56,7 +56,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
A plugin calls `ctx.sandboxPolicy.registerWritableRoot(session, directory)` through its owning context to add one physical absolute directory to that live Session's `workspace-write` permissions. The method returns the exact Cordis effect disposer; plugin unload or explicit disposal removes only that registration. Duplicate registrations and configured grants remain independent. Mount session-specific plugins under the Agent's context so their effects also end with the Agent. Registration validates the path spelling; the directory must exist on the execution host before an operation uses the grant. The same path checks as configured roots apply.
-Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only. On Windows, existing extra directories cause new `workspace-write` subprocess confinement to fail, even for commands that do not access those directories; see [sandbox-local](../sandbox-local/README.md#known-limitations-and-deferred-work).
+Registration and disposal affect subsequent policy resolutions and the next model request's logged context. Other Sessions, including subagent Sessions, and agentless calls receive no such grant. Grants are not saved or inherited through Session history; a resumed or forked Session needs its own registration. Already-resolved operations and persistent processes keep their original permissions, including directories whose registration has been removed. Start a new process to use the current grants. `read-only` remains read-only.
### Switching a session's mode
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index f2d3a88632..377b6db5bd 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -56,7 +56,7 @@ kind: "package-reference"
插件通过自己的所属上下文调用 `ctx.sandboxPolicy.registerWritableRoot(session, directory)`,为该活跃 Session 的 `workspace-write` 权限添加一个物理绝对目录。该方法返回原始 Cordis effect 清理函数;插件卸载或显式清理只移除该次注册。重复注册与配置授权彼此独立。将会话专用插件挂载在 Agent 的上下文下,使其 effect 也随 Agent 结束。注册时验证路径写法;操作使用该授权前,目录必须已存在于执行主机上。路径检查与配置根目录相同。
-注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。在 Windows 上,已存在的额外目录会使新的 `workspace-write` 子进程约束失败,即使命令不访问这些目录;参见 [sandbox-local](../sandbox-local/README.zh.md#known-limitations-and-deferred-work)。
+注册与清理影响后续策略解析及下一次模型请求中记录的上下文。其他 Session(包括子 agent 的 Session)与无 agent 调用不会获得此授权。授权不随 Session 历史保存或继承;恢复或派生的 Session 需要自行注册。已经解析策略的操作与持久进程保留原权限,包括注册已被移除的目录。启动新进程才能使用当前授权。`read-only` 仍然只读。
### 切换会话模式
diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
index 94a3e37675..e778ee1934 100644
--- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
+++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
@@ -9,8 +9,8 @@
en: e7cdbd2652fe80ab
zh: 04e1d187313a0bd5
/deepseek-ai-dsh-sandbox-windows-acl/summary:
- en: 777921135c23ee55
- zh: 7c726cc63995d443
+ en: dac35dbc120ce3ec
+ zh: b46a5c0d31af6f1f
/deepseek-ai-dsh-sandbox-windows-acl/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -24,8 +24,8 @@
en: 16596607bbccc2a3
zh: 30bc370a5281bf0d
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you:
- en: 289e65bddf20dd79
- zh: 7ff72ed64c4b4d1c
+ en: 0ae33482d28f637b
+ zh: a2b87b18737a6d07
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery:
en: 7fc0707debb1e67c
zh: e3b5529bf82f868e
@@ -33,14 +33,14 @@
en: c2cde86c85c2c619
zh: a29a114e591f9d3f
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism:
- en: a37ea37f2d527693
- zh: 749b5a263d0e1534
+ en: 97ef643f608ae7d8
+ zh: bf4eb7941f4573f6
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists:
- en: 9c8d9af84f9abd99
- zh: 1c82b355aa5f210f
+ en: 1a4ed5c06a5be74d
+ zh: 053cb81bd76260ed
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner:
- en: 0847de0e53e50061
- zh: 90fa3dcaf080c3be
+ en: de2dce4b18f77dad
+ zh: bbe7eef92b441364
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries:
en: ad02570eae90037f
zh: fd78c2318de18dda
diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md
index 732ca20a35..9344325c43 100644
--- a/packages/sandbox/sandbox-windows-acl/README.md
+++ b/packages/sandbox/sandbox-windows-acl/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable.
+On Windows, this package confines child-process writes and deletes to the workspace, explicit extra directories, and a private temporary directory: `workspace-write` grants these directories, `read-only` grants none. Mount `dsh-sandbox-local` for confined bash and PowerShell commands, or use `AclSandbox`; Win32 failures never cause unrestricted spawning. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable.
## Table of Contents
@@ -67,7 +67,7 @@ The workspace security descriptor edits are granted standing — `dispose()` lea
### What confinement gives you
-Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries.
+Under `workspace-write`, the child may write into the workspace, its explicit extra roots, and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries.
Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session.
@@ -95,11 +95,11 @@ This section explains the restricted-token mechanism, the token lists, the runne
### Mechanism
-The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
+The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `workspaceWriteSid(workspaceRoot, additionalWritableRoots)` hashes the sorted, deduplicated canonical directory set with unambiguous JSON encoding; a workspace-only set preserves its existing SID. Each execution resolves or materializes standing ACEs for that exact set. Adding or removing a directory selects a different SID for new processes, while running processes keep their launch token. Equal sets reuse their standing security descriptor edits through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
### Modes and token lists
-`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
+`workspace-write` (logon SID, Everyone, directory-set SID, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working.
@@ -110,10 +110,10 @@ Authenticated Users is absent from both lists — the WMI namespace security che
The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's runner-failure rules match that signature.
```sh
-node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] --
+node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] --
```
-The seam materializes the deterministic workspace SID's ACE standing (once per workspace per server lifetime — the reuse cache), then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A workspace equal to or containing the temp root is rejected before any grant.
+The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry for the exact directory set, rechecking each root's ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies the directory-set SID against all selected roots and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation.
diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md
index ad9de70659..2f11123c00 100644
--- a/packages/sandbox/sandbox-windows-acl/README.zh.md
+++ b/packages/sandbox/sandbox-windows-acl/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
-在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
+在 Windows 上,本包将子进程的写入与删除限制在工作区、显式额外目录和私有临时目录内:`workspace-write` 授予这些目录,`read-only` 均不授予。挂载 `dsh-sandbox-local` 可约束 bash 与 PowerShell 命令,也可使用 `AclSandbox`;Win32 失败绝不会导致不受限制的进程启动。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。
## 目录
@@ -67,7 +67,7 @@ rmSync(tempDir, { recursive: true, force: true })
### 隔离给你带来什么
-在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
+在 `workspace-write` 下,子进程可以写入工作区、显式额外根目录及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。
临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。
@@ -97,11 +97,11 @@ rmSync(tempDir, { recursive: true, force: true })
### 机制
-调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
+调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`workspaceWriteSid(workspaceRoot, additionalWritableRoots)` 对排序、去重后的规范目录集合使用无歧义的 JSON 编码求哈希;仅含工作区的集合保留现有 SID。每次执行查找或物化完全匹配该集合的常驻 ACE。增加或移除目录会为新进程选择不同的 SID,运行中的进程保留启动令牌。相同集合通过精确 ACE/精确拒绝/精确标签跳过,复用常驻安全描述符改动。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
### 模式与令牌列表
-`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
+`workspace-write`(登录 SID、Everyone、目录集合 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。
@@ -112,10 +112,10 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全
面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 runner 失败规则匹配该签名。
```sh
-node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] --
+node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] --
```
-seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服务器生命周期一次——复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。工作区若等于或包含临时根目录,会在任何授权前被拒绝。
+seam 通过重复的 `--writable-root` 参数传入额外根目录,并为完全相同的目录集合复用授权项,每次执行前重新检查各根目录的 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 根据全部选定根目录验证目录集合 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。
启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。
diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts
index 7f7bcc4c57..2316b0f6ed 100644
--- a/packages/sandbox/sandbox-windows-acl/src/grant.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts
@@ -1,7 +1,7 @@
/**
* Server-side write-grant materialization. The sandbox seam holds one
- * standing workspace grant per workspace and one revocable temp grant per
- * live session/workspace pair. Workspace identities survive by deterministic
+ * standing grant per writable-directory set and one revocable temp grant per
+ * live session/workspace pair. Directory-set identities survive by deterministic
* derivation and their standing ACE; temp identities derive from random
* private paths and are deliberately new after a restart.
*
@@ -91,7 +91,8 @@ export class AclWriteGrant {
* Grant the write ACE, the ambient-delete deny, and the Low mandatory label
* on one directory (idempotent: an already-standing exact ACE, deny, and
* label skip the eager full-tree re-propagation — see {@link grantWrite})
- * and record the path for {@link dispose} unless it is standing. The path is
+ * and record the path once for {@link dispose} unless it is standing. Repeated
+ * calls recheck the ACE so a recreated directory receives its grant. The path is
* recorded BEFORE the grant: a post-apply throw (a LocalFree failure after
* SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an
* ungranted path is a no-op merge. Callers treat a throw as a failed
@@ -103,7 +104,8 @@ export class AclWriteGrant {
* dispose — the temp-directory lifecycle).
*/
add(path: string, standing = false): void {
- ;(standing ? this.standingPaths : this.revocablePaths).push(path)
+ const paths = standing ? this.standingPaths : this.revocablePaths
+ if (!paths.includes(path)) paths.push(path)
grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr)
}
diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts
index 5de5d47b72..9413dc60c2 100644
--- a/packages/sandbox/sandbox-windows-acl/src/index.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/index.ts
@@ -13,13 +13,11 @@
* for the complete boundary). The intersection covers only the object's own
* access check, so the token is also lowered to Low integrity and every
* granted directory carries a Low no-write-up label and the ambient-delete
- * deny the `acl` module documents. The write SID is the per-WORKSPACE identity
- * ({@link workspaceWriteSid}): deterministic from the canonical workspace
- * path, so the workspace-root ACE materializes once per workspace per
- * machine and every later provision hits the exact-ACE skip — the
- * grant-reuse story the per-session random SID paid a full tree propagation
- * per session for. Each private temp directory instead receives its own SID,
- * so sibling sessions sharing a workspace cannot enter one another's temp
+ * deny the `acl` module documents. The write SID identifies the complete
+ * canonical writable-directory set ({@link workspaceWriteSid}). Standing
+ * ACEs are reusable across calls and sessions with that exact set. Each
+ * private temp directory instead receives its own SID, so sibling sessions
+ * cannot use a shared directory-set capability to enter one another's temp
* trees. Unlike the POC, every API failure throws with the API
* name and exact Win32 code; a child is NEVER spawned unrestricted.
*
@@ -56,7 +54,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce
import * as abi from './win32-abi.ts'
export { AclWriteGrant } from './grant.ts'
-export { assertTempRootOutsideWorkspace } from './path-boundary.ts'
+export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts'
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts'
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
@@ -72,9 +70,9 @@ export interface AclSandboxOptions {
/**
* The write SID forming the workspace-write allowlist: REQUIRED under
* workspace-write, ignored (and must be absent) under read-only. Callers
- * derive it from the workspace via {@link workspaceWriteSid} — the identity
- * is per workspace, not per sandbox instance, so the workspace-root ACE
- * outlives every instance and later provisions hit the exact-ACE skip.
+ * derive it from the complete canonical writable-directory set via
+ * {@link workspaceWriteSid}. Standing ACEs outlive sandbox instances and
+ * are reusable by later executions carrying that exact set identity.
*/
writeSid?: string
/**
@@ -163,7 +161,7 @@ function freeSidBestEffort(
export class AclSandbox {
/** Absolute writable directories (constructor-validated). */
readonly writableDirs: string[]
- /** The workspace SID string whose ACEs form the workspace allowlist. */
+ /** The directory-set SID whose ACEs form the writable allowlist. */
readonly writeSid: string | undefined
/** The private temp directory's write SID (workspace-write with temp only). */
readonly tempWriteSid: string | undefined
@@ -194,7 +192,7 @@ export class AclSandbox {
this.writeSid = options.writeSid
this.tempWriteSid = options.tempWriteSid
if (this.mode === 'workspace-write' && this.writeSid === undefined) {
- throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()')
+ throw new Error('AclSandbox workspace-write requires a write SID — derive it from the writable directories via workspaceWriteSid()')
}
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
@@ -256,7 +254,7 @@ export class AclSandbox {
// manageDacls: false — the caller (the sandbox seam's grant) already
// materialized the ACEs; this instance must neither add nor remove any.
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
- // per-workspace reuse cache — dispose() never revokes them, or the next
+ // directory-set reuse cache — dispose() never revokes them, or the next
// provision would re-propagate the whole tree) and the temp ACE is
// REVOCABLE (dispose() removes it before the private directory is
// deleted; the ambient temp root is never granted).
diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts
index 8c673780a2..155c5a5c82 100644
--- a/packages/sandbox/sandbox-windows-acl/src/runner.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts
@@ -1,7 +1,7 @@
/**
* The windows-acl confinement runner: the argv-prefix wrapper the sandbox
* seam spawns in place of the caller's command. It creates the
- * WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the
+ * WRITE_RESTRICTED token with the directory-set write-SID allowlist, spawns the
* wrapped argv under it with the CALLER'S stdio inherited (bytes flow
* straight through), mirrors the child's exit code, and revokes its temp
* grant on exit (workspace ACEs stay standing as the reuse cache).
@@ -9,12 +9,12 @@
* Stable argv contract (the seam builds it; a native-exe replacement would
* keep the same contract):
* [node, runner.js, '--workspace', , '--temp', ,
- * '--mode', ,
+ * '--mode', , ['--writable-root', ]...,
* ['--write-sid', ,
* '--temp-write-sid', ], '--', ]
*
* Modes:
- * - workspace-write: the workspace and temp directories carry distinct
+ * - workspace-write: the writable-directory set and private temp carry distinct
* capability-SID Write grants; other ACL-addressable writes are denied
* except for the documented Everyone and hard-link boundaries.
* - read-only: no capability-SID grants; the restricting list carries no
@@ -27,7 +27,7 @@
* `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
* CALLER has already materialized distinct workspace and private-temp ACEs
* and owns their revocation, so the runner neither grants nor revokes
- * (`manageDacls: false`). Both values are checked against their owning paths.
+ * (`manageDacls: false`). Both values are checked against their owning directory set or private path.
* Without the pair (standalone/agentless use), workspace-write treats
* `--temp` as a ROOT, creates a random private child directory, derives its
* own temp SID, and removes that directory after the child exits. In both
@@ -67,6 +67,7 @@ interface ParsedArgs {
workspace: string
temp: string
mode: 'read-only' | 'workspace-write'
+ writableRoots: string[]
writeSid: string | undefined
tempWriteSid: string | undefined
command: string
@@ -74,6 +75,7 @@ interface ParsedArgs {
}
function parseArgs(raw: string[]): ParsedArgs {
+ const writableRoots: string[] = []
let workspace: string | undefined
let temp: string | undefined
let mode: string | undefined
@@ -91,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs {
if (value === undefined) fail(`missing value after ${token}`)
switch (token) {
case '--workspace': workspace = value; break
+ case '--writable-root': writableRoots.push(value); break
case '--temp': temp = value; break
case '--mode': mode = value; break
case '--write-sid': writeSid = value; break
@@ -104,7 +107,7 @@ function parseArgs(raw: string[]): ParsedArgs {
const argv = raw.slice(index)
const command = argv[0]
if (command === undefined) fail('missing command after --')
- return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
+ return { workspace, temp, mode, writableRoots, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
}
function requireDirectory(label: string, path: string): void {
@@ -119,8 +122,13 @@ async function main(): Promise {
// a bogus root must fail loudly at the runner boundary, never mid-child.
requireDirectory('--workspace', parsed.workspace)
requireDirectory('--temp', parsed.temp)
+ for (const root of parsed.writableRoots) requireDirectory('--writable-root', root)
+ const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots])]
const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined
+ if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) {
+ fail('read-only does not accept --writable-root')
+ }
if (parsed.mode === 'read-only' && seamManaged) {
fail('read-only does not accept --write-sid or --temp-write-sid')
}
@@ -128,7 +136,7 @@ async function main(): Promise {
fail('workspace-write requires --write-sid and --temp-write-sid together')
}
if (parsed.mode === 'workspace-write') {
- assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp)
+ for (const root of writableDirs) assertTempRootOutsideWorkspace(root, parsed.temp)
}
const api = await win32()
@@ -147,9 +155,9 @@ async function main(): Promise {
let writeSid: string | undefined
let privateTempSid: string | undefined
if (parsed.mode === 'workspace-write') {
- writeSid = workspaceWriteSid(parsed.workspace)
+ writeSid = workspaceWriteSid(parsed.workspace, parsed.writableRoots)
if (seamManaged) {
- if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace')
+ if (parsed.writeSid !== writeSid) fail('--write-sid does not match writable directories')
privateTempDir = parsed.temp
privateTempSid = tempWriteSid(privateTempDir)
if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp')
@@ -160,7 +168,7 @@ async function main(): Promise {
}
}
sandbox = new AclSandbox({
- writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
+ writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [],
tempDir: privateTempDir,
mode: parsed.mode,
...writeSid === undefined ? {} : { writeSid },
diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts
index db313ce092..58c9c0dd4e 100644
--- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts
@@ -1,39 +1,24 @@
/**
- * The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived
- * from the canonical workspace path, whose ACEs form that workspace's write
- * allowlist. Every confined execution of the same workspace — across
- * sessions, server restarts, and calls — carries the SAME write SID, so the
- * workspace-root ACE materializes once per workspace per machine (the
- * grant's exact-ACE skip then makes every later provision O(1)) instead of
- * once per session. The SID's power is defined solely by the ACEs that name
- * it (which exist only on the workspace tree and the session's private temp
- * directory), and only tokens minted for that workspace carry it — the SID
- * string itself is not a secret. Temporary directories use a separate,
- * per-directory identity from {@link tempWriteSid}; sharing the workspace
- * identity with temp would let sibling sessions write one another's temp
- * trees.
- *
- * The input MUST be the canonical workspace path (`realpathSync.native` on
- * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
- * canonicalization converges case/alias spellings, so two spellings of one
- * workspace derive one SID; an as-spelled fallback path would mint a second
- * identity for the same directory (self-healing, at the cost of one extra
- * tree propagation). Renaming the workspace directory derives a new SID —
- * the old standing ACEs are inert residue, and the next session re-propagates
- * once.
+ * Deterministic write identities for canonical writable-directory sets and
+ * separate identities for private temp directories. Standing directory ACEs
+ * are reusable only by executions carrying the same complete set identity.
* @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid
*/
import { createHash } from 'node:crypto'
/**
- * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
- * matching the workspace-capability shape the token and ACE layers carry).
+ * Derive the write SID for a workspace and its additional writable roots.
+ * Order and duplicate entries do not affect the identity. A workspace-only
+ * set retains its existing SID; larger sets use unambiguous JSON encoding.
* @param workspaceRoot - the canonical workspace path.
- * @returns the SDDL string form.
+ * @param additionalWritableRoots - canonical extra directories; defaults to none.
+ * @returns the deterministic SDDL string for the complete directory set.
*/
-export function workspaceWriteSid(workspaceRoot: string): string {
- const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest()
+export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots: readonly string[] = []): string {
+ const roots = [...new Set([workspaceRoot, ...additionalWritableRoots])].sort()
+ const identity = roots.length === 1 ? workspaceRoot : JSON.stringify(roots)
+ const digest = createHash('sha256').update(identity, 'utf8').digest()
const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
return `S-1-4-${first}-${second}`
diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts
index 43810249ab..acaa4a010d 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts
@@ -8,7 +8,7 @@
*/
import { spawnSync } from 'node:child_process'
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -53,6 +53,7 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
// grant stays exactly one ACE (the reuse across sessions/restarts).
grant.add(dir)
grant.add(standingDir, true)
+ expect(grant.paths).toEqual([standingDir, dir])
expect(icaclsText(dir)).toContain('S-1-4-9000-77')
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
grant.dispose()
@@ -60,6 +61,20 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
})
+ it('restores a standing ACE on a recreated directory without duplicating its tracked path', () => {
+ const dir = scratch()
+ const grant = AclWriteGrant.create('S-1-4-9000-80')
+ try {
+ grant.add(dir, true)
+ rmSync(dir, { recursive: true })
+ mkdirSync(dir)
+ expect(icaclsText(dir)).not.toContain(grant.writeSid)
+ grant.add(dir, true)
+ expect(icaclsText(dir)).toContain(grant.writeSid)
+ expect(grant.paths).toEqual([dir])
+ } finally { grant.dispose() }
+ })
+
it('two grants with different SIDs coexist and revoke independently', () => {
const dir = scratch()
const grantA = AclWriteGrant.create('S-1-4-9000-78')
diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts
index ebac7ffa0e..413afe2f69 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts
@@ -107,6 +107,62 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
}, 30_000)
+ it('new processes use the exact writable set despite standing grants from a broader set', () => {
+ const extra = mkdtempSync(join(scratchRoot, 'extra-set-'))
+ const workspaceFile = join(writableDir, 'set-workspace.txt')
+ const extraFile = join(extra, 'set-extra.txt')
+ const probe = "const fs = require('node:fs'); for (const path of process.argv.slice(1)) { try { fs.writeFileSync(path, 'written'); console.log('ALLOW ' + path) } catch { console.log('DENY ' + path) } }"
+ const expanded = runRunner([
+ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
+ '--writable-root', extra,
+ '--', process.execPath, '-e', probe, workspaceFile, extraFile,
+ ])
+ expect(expanded.error).toBeUndefined()
+ expect(expanded.signal).toBeNull()
+ expect(expanded.status, expanded.stderr).toBe(0)
+ expect(readFileSync(extraFile, 'utf8')).toBe('written')
+ writeFileSync(extraFile, 'retained')
+
+ const narrowed = runRunner([
+ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
+ '--', process.execPath, '-e', probe, workspaceFile, extraFile,
+ ])
+ expect(narrowed.error).toBeUndefined()
+ expect(narrowed.signal).toBeNull()
+ expect(narrowed.status, narrowed.stderr).toBe(0)
+ expect(narrowed.stdout).toContain(`ALLOW ${workspaceFile}`)
+ expect(narrowed.stdout).toContain(`DENY ${extraFile}`)
+ expect(readFileSync(extraFile, 'utf8')).toBe('retained')
+ }, 90_000)
+
+ it('accepts a provider-materialized SID covering the workspace and additional directories', () => {
+ const extra = mkdtempSync(join(scratchRoot, 'managed-extra-'))
+ const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-'))
+ const writeSid = workspaceWriteSid(writableDir, [extra])
+ const privateTempSid = tempWriteSid(privateTemp)
+ const directoryGrant = AclWriteGrant.create(writeSid)
+ const tempGrant = AclWriteGrant.create(privateTempSid)
+ try {
+ directoryGrant.add(writableDir, true)
+ directoryGrant.add(extra, true)
+ tempGrant.add(privateTemp)
+ const target = join(extra, 'managed-write.txt')
+ const result = runRunner([
+ '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write',
+ '--writable-root', extra, '--write-sid', writeSid, '--temp-write-sid', privateTempSid,
+ '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target,
+ ])
+ expect(result.error).toBeUndefined()
+ expect(result.signal).toBeNull()
+ expect(result.status, result.stderr).toBe(0)
+ expect(readFileSync(target, 'utf8')).toBe('managed')
+ } finally {
+ directoryGrant.dispose()
+ tempGrant.dispose()
+ rmSync(privateTemp, { recursive: true, force: true })
+ }
+ }, 90_000)
+
it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM availability follows the caller token', () => {
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
@@ -642,11 +698,24 @@ TryOpen 'DIRECTORY' '${child}'
expect(result.stderr).toContain('windows-acl-run: ')
}, 15_000)
+ it('read-only rejects writable-directory flags before starting the command', () => {
+ const result = runRunner([
+ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
+ '--writable-root', worldWritableDir,
+ '--', process.execPath, '-e', 'process.exit(99)',
+ ])
+ expect(result.error).toBeUndefined()
+ expect(result.signal).toBeNull()
+ expect(result.status, result.stderr).toBe(127)
+ expect(result.stderr).toContain('read-only does not accept --writable-root')
+ }, 90_000)
+
it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => {
const writeSid = workspaceWriteSid(writableDir)
const tempSid = tempWriteSid(isolatedTemp)
const cases = [
['--write-sid', writeSid],
+ ['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid],
['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid],
['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'],
]
diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts
index 7fdef07d53..82b9d055e0 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts
@@ -19,6 +19,23 @@ describe('workspaceWriteSid', () => {
expect(first).toMatch(/^S-1-4-\d+-\d+$/u)
})
+ it('uses the same identity for reordered and duplicated writable-directory sets', () => {
+ const workspace = 'C:\\repo'
+ const memory = 'C:\\memory'
+ const cache = 'C:\\cache'
+ const sid = workspaceWriteSid(workspace, [memory, cache])
+ expect(workspaceWriteSid(workspace, [cache, workspace, memory, cache])).toBe(sid)
+ expect(workspaceWriteSid(memory, [workspace, cache])).toBe(sid)
+ expect(workspaceWriteSid(workspace, [workspace])).toBe(workspaceWriteSid(workspace))
+ expect(sid).not.toBe(workspaceWriteSid(workspace))
+ expect(sid).not.toBe(workspaceWriteSid(workspace, [memory]))
+ })
+
+ it('keeps path boundaries distinct when their concatenated text matches', () => {
+ expect(workspaceWriteSid('C:\\a', ['C:\\bC:\\c']))
+ .not.toBe(workspaceWriteSid('C:\\aC:\\b', ['C:\\c']))
+ })
+
it('derives distinct identities for distinct workspaces', () => {
expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b'))
})
diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts
index 0fa3183987..01d14f51ef 100644
--- a/packages/sandbox/sandbox/src/index.ts
+++ b/packages/sandbox/sandbox/src/index.ts
@@ -53,7 +53,7 @@ export interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
- * while the workspace SID and standing grant remain per-workspace); absent
+ * while standing grants are keyed by the complete writable-directory set); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
From 0120f0c01e50d75565397d860f68f1638d466ef0 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 28 Oct 2026 18:27:51 +0800
Subject: [PATCH 026/106] refactor(sandbox): compose Windows write permissions
per directory
---
...ssion-writable-directory-effects.i18n.yaml | 4 +-
...0-26-session-writable-directory-effects.md | 2 +-
...6-session-writable-directory-effects.zh.md | 2 +-
...ndows-directory-write-identities.i18n.yaml | 19 +++
...0-28-windows-directory-write-identities.md | 25 ++++
...8-windows-directory-write-identities.zh.md | 25 ++++
...ows-acl-restricted-token-sandbox.i18n.yaml | 8 +-
...08-windows-acl-restricted-token-sandbox.md | 4 +-
...windows-acl-restricted-token-sandbox.zh.md | 4 +-
docs/subsystems/sandbox.md | 2 +-
docs/subsystems/sandbox.zh.md | 2 +-
.../windows-directory-sids/guide.i18n.yaml | 16 +++
.../windows-directory-sids/guide.md | 19 +++
.../windows-directory-sids/guide.zh.md | 19 +++
.../sandbox/sandbox-local/README.i18n.yaml | 4 +-
packages/sandbox/sandbox-local/README.md | 2 +-
packages/sandbox/sandbox-local/README.zh.md | 2 +-
packages/sandbox/sandbox-local/src/index.ts | 44 +++----
.../sandbox-local/tests/acl-grants.spec.ts | 78 ++++++------
.../sandbox-windows-acl/README.i18n.yaml | 12 +-
.../sandbox/sandbox-windows-acl/README.md | 11 +-
.../sandbox/sandbox-windows-acl/README.zh.md | 11 +-
.../sandbox/sandbox-windows-acl/src/grant.ts | 4 +-
.../sandbox/sandbox-windows-acl/src/index.ts | 83 ++++++-------
.../sandbox/sandbox-windows-acl/src/runner.ts | 41 +++----
.../sandbox/sandbox-windows-acl/src/token.ts | 4 +-
.../sandbox-windows-acl/src/workspace-sid.ts | 23 ++--
.../sandbox-windows-acl/tests/acl.spec.ts | 56 ++++++---
.../tests/index-failure-paths.spec.ts | 115 ++++++++++++++----
.../sandbox-windows-acl/tests/probe.spec.ts | 7 +-
.../sandbox-windows-acl/tests/runner.spec.ts | 37 +++---
.../tests/workspace-sid.spec.ts | 46 ++-----
packages/sandbox/sandbox/src/index.ts | 2 +-
33 files changed, 454 insertions(+), 279 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
create mode 100644 .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml
create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
create mode 100644 docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
index 1368af8de4..ea30576b82 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.i18n.yaml
@@ -9,8 +9,8 @@
en: 2a32db1f27371403
zh: 89cf04705ef66769
/agent-note-session-writable-directory-effects/decision:
- en: 504c8d2399c47110
- zh: 6c30739907a86f07
+ en: 1ce0f8414a0f9db1
+ zh: 0d2da6cf8ac2adbb
/agent-note-session-writable-directory-effects/alternatives-considered:
en: dad77744dea17bde
zh: bb3838f9f9099944
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
index bb3a47036b..edbd205025 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.md
@@ -16,7 +16,7 @@ Registration disposal affects subsequent operations, not already-resolved calls
Missing, non-directory, and symlinked extra paths stop execution with `SandboxInvalidRootError`, which identifies the path and its problem. Silently skipping a path would hide an invalid grant and let execution proceed with permissions different from those requested. Registration checks path spelling; enforcement validates the physical directory on each call without retaining an earlier resolution. Symlinks remain disallowed, keeping configured and plugin grants under the same validation rules.
-Windows standing ACL grants use one deterministic SID per complete sorted, deduplicated canonical directory set. Each new execution resolves or creates that exact set's grant entry; changing the set never broadens the SID of a process already running. Reusing only the workspace identity would accumulate extra-directory authority across Sessions and after disposal. JSON encoding preserves directory boundaries in the hash input; singleton sets retain their existing SID. Standing ACEs remain reusable, while private temp grants keep their separate per-session/workspace identity and lifetime.
+Windows standing ACL grants follow the [per-directory identity decision](2026-10-28-windows-directory-write-identities.md): each new execution carries only its currently permitted directory SIDs, while private temp capabilities remain separate.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
index 9488ee695a..67d5c9bbf5 100644
--- a/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-26-session-writable-directory-effects.zh.md
@@ -16,7 +16,7 @@ sandbox-policy 服务拥有以活跃 Session 身份为键、受 effect 生命周
不存在、不是目录或经过符号链接的额外路径会以 `SandboxInvalidRootError` 阻止执行,错误指出路径及其问题。静默跳过路径会隐藏无效授权,使执行时的权限与请求的权限不同。注册时检查路径写法;执行限制时每次验证物理目录,不保留先前的解析结果。符号链接仍被禁止,使配置授权和插件授权遵循相同的验证规则。
-Windows 常驻 ACL 授权为完整、排序去重后的规范目录集合使用一个确定性 SID。每次新执行查找或创建完全匹配该集合的授权项;集合变化不会扩大已运行进程的 SID 权限。如果仅复用工作区身份,额外目录权限就会在不同 Session 之间及注册清理后累积。JSON 编码在哈希输入中保留目录边界;单目录集合保留现有 SID。常驻 ACE 可继续复用,私有临时授权则保持独立的每会话/工作区身份与生命周期。
+Windows 常驻 ACL 授权遵循[逐目录身份决策](2026-10-28-windows-directory-write-identities.zh.md):每次新执行仅携带当前允许的目录 SID,私有临时能力继续独立。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml
new file mode 100644
index 0000000000..583e918ef5
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-10-28-windows-directory-write-identities.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
+/agent-note-windows-per-directory-write-identities:
+ en: df2c97b00cabb275
+ zh: e09a61baf823ff5e
+/agent-note-windows-per-directory-write-identities/problem:
+ en: ce436e11d96c3c96
+ zh: dcda6f85413a5be6
+/agent-note-windows-per-directory-write-identities/decision:
+ en: edbc0f1001721065
+ zh: a91b9b2c1e19e6d2
+/agent-note-windows-per-directory-write-identities/alternatives-considered:
+ en: d87e2cf9196d66e7
+ zh: 04261fa61efe407b
+/agent-note-windows-per-directory-write-identities/consequences:
+ en: b9c9e40cf1e09f4e
+ zh: 6e69f792bbdd7726
diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
new file mode 100644
index 0000000000..7076514a10
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
@@ -0,0 +1,25 @@
+# Agent Note: Windows per-directory write identities
+
+Status: implemented
+
+English | [中文](2026-10-28-windows-directory-write-identities.zh.md)
+
+## Problem
+
+[Session-scoped writable roots](2026-10-26-session-writable-directory-effects.md) let independent plugins add directories to an execution's policy. A SID derived from the complete directory set requires additional standing ACEs and tree propagation on unchanged directories whenever that combination changes.
+
+## Decision
+
+Each canonical writable directory has one deterministic SID, granted only to that directory. Each new execution carries the SIDs for all its permitted directories. The provider caches grants by directory and rechecks their ACEs before execution so directory recreation does not invalidate the cache. Shared directories reuse the same standing grant across Sessions and permission combinations; a Session without the directory's SID receives no authority from that grant.
+
+`AclSandbox` derives identities from canonical, deduplicated paths instead of accepting a caller-selected SID for an arbitrary list. Every parsed SID has an allocation owner, including partial initialization failures. Private temp capabilities remain separate per live Session/workspace pair; the token default DACL still prefers the private temp SID. Running processes retain their launch tokens when later executions change their permitted roots.
+
+## Alternatives considered
+
+**One SID per complete directory set.** Sorted, deduplicated JSON encoding gives an unambiguous identity and separates differently authorized executions. It also adds another ACE to every unchanged directory for each new combination and repeats tree propagation. Per-directory identities provide the same future-execution semantics with independent reuse.
+
+**One workspace SID extended to extra directories.** This accumulates authority for processes that already hold the workspace SID and for other Sessions using that workspace. Adding a directory must select another existing directory capability, not expand a shared capability's meaning.
+
+## Consequences
+
+Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The underlying restricted-token and hard-link limitations remain unchanged.
diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md
new file mode 100644
index 0000000000..ecaab18b84
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md
@@ -0,0 +1,25 @@
+# Agent Note: Windows 逐目录写入身份
+
+Status: implemented
+
+[English](2026-10-28-windows-directory-write-identities.md) | 中文
+
+## 问题
+
+[会话级可写根目录](2026-10-26-session-writable-directory-effects.zh.md)允许独立插件向执行策略添加目录。如果 SID 由完整目录集合派生,每次组合变化都需要向未变化的目录添加常驻 ACE,并重新传播整棵目录树。
+
+## 决策
+
+每个规范可写目录拥有一个确定性 SID,仅在该目录上授予它。每次新执行携带全部允许目录的 SID。提供方按目录缓存授权,并在执行前重新检查 ACE,避免目录重建使缓存失效。共享目录在不同 Session 和权限组合之间复用同一个常驻授权;未携带该目录 SID 的 Session 无法从该授权获得权限。
+
+`AclSandbox` 从规范化、去重后的路径派生身份,不接受调用方为任意目录列表选择一个 SID。每个解析后的 SID 都有明确的分配所有者,包括部分初始化失败时。私有临时能力仍按活跃 Session/工作区对独立;令牌默认 DACL 继续优先使用私有临时 SID。后续执行改变允许根目录时,已运行进程保留启动令牌。
+
+## 考虑过的替代方案
+
+**每个完整目录集合一个 SID。** 排序、去重后的 JSON 编码可生成无歧义身份,并隔离不同权限的执行。但每种新组合都会向未变化的目录添加另一个 ACE,并重复目录树传播。逐目录身份通过独立复用提供相同的后续执行语义。
+
+**将工作区 SID 扩展到额外目录。** 这会为已经持有工作区 SID 的进程及使用该工作区的其他 Session 累积权限。添加目录必须选择另一个已有目录能力,而不能扩大共享能力的含义。
+
+## 影响
+
+已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。底层受限令牌及硬链接限制保持不变。
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
index 87816ade93..8217e734a6 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
@@ -9,8 +9,8 @@
en: f62d87b7623a9904
zh: 48bbb7c35298f96c
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
- en: 7c8a053085200aee
- zh: dd36659227430d93
+ en: 0cd47aef022d86ad
+ zh: 1f8c5006db51c97b
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
@@ -30,8 +30,8 @@
en: e2c573d90c4ddeb9
zh: f0259213e1321722
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences:
- en: f14d0c8fb77d57ab
- zh: 1842f13d91de8bd2
+ en: 5b9657f4247b6a79
+ zh: e024d34bbab082a7
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing:
en: b223cb2cf13193c9
zh: 61ddd0a0b99f8f56
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
index acb525290b..412933dbf1 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
@@ -10,7 +10,7 @@ The original [historical sandbox decision](../../archived/feature/2026-07-06-san
## Decision
-Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The write SID is derived deterministically from the complete canonical writable-directory set (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing ACEs are the reuse cache for that exact set, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
+Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
## ACL diagnosis and recovery
@@ -40,7 +40,7 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
## Consequences
-Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory set per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
+Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
## Testing
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
index 70a705f30f..daea838039 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## 决策
-直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。写入 SID 由完整的规范可写目录集合确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 是完全相同集合的复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
+直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
## ACL 诊断与恢复
@@ -41,7 +41,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
## 后果
-所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录集合只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
+所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
## 测试
diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md
index 8b19c57010..f52a93e51a 100644
--- a/docs/subsystems/sandbox.md
+++ b/docs/subsystems/sandbox.md
@@ -64,7 +64,7 @@ interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
- * while standing grants are keyed by the complete writable-directory set); absent
+ * while standing grants are keyed by individual writable directories); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md
index 8c0df187c3..10fd3ad925 100644
--- a/docs/subsystems/sandbox.zh.md
+++ b/docs/subsystems/sandbox.zh.md
@@ -64,7 +64,7 @@ interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
- * while standing grants are keyed by the complete writable-directory set); absent
+ * while standing grants are keyed by individual writable directories); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml
new file mode 100644
index 0000000000..15f12a8dbc
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
+/:
+ en: dbd8a58fb86d9bd4
+ zh: 1e8f6661b7c9122d
+/windows-acl-directory-identities:
+ en: fd8ac37f696dac64
+ zh: b5531e3c276b0c88
+/windows-acl-directory-identities/change:
+ en: ae9055a597ae4e32
+ zh: 9fe43036ec0372ac
+/windows-acl-directory-identities/migration:
+ en: 601f6832ab87b74f
+ zh: 568afc8d964c4da7
diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
new file mode 100644
index 0000000000..fb0ab47ad2
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
@@ -0,0 +1,19 @@
+---
+kind: upgrade-guide
+description: "Windows ACL callers use derived per-directory SIDs instead of supplying one shared write SID."
+---
+
+# Windows ACL directory identities
+
+English | [中文](guide.zh.md)
+
+## Change
+
+`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. Users of the standard `dsh` profiles need no configuration changes.
+
+## Migration
+
+1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities.
+2. Replace `workspaceWriteSid(path)` imports with `directoryWriteSid(path)` when materializing grants outside `AclSandbox`. Give each canonical directory only its own SID; do not grant one SID across several directories.
+3. For caller-managed runner invocations, repeat `--write-sid` once per canonical directory, matching the deduplicated workspace-then-extra-root order, and retain `--temp-write-sid`. Agentless invocations omit both kinds of SID flag.
+4. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions.
diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md
new file mode 100644
index 0000000000..c9bd0b60c4
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md
@@ -0,0 +1,19 @@
+---
+kind: upgrade-guide
+description: "Windows ACL 调用方使用派生的逐目录 SID,不再提供一个共享的写入 SID。"
+---
+
+# Windows ACL 目录身份
+
+[English](guide.md) | 中文
+
+## 变更
+
+`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。标准 `dsh` profile 的用户无需修改配置。
+
+## 迁移
+
+1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。
+2. 在 `AclSandbox` 之外物化授权时,将 `workspaceWriteSid(path)` 导入替换为 `directoryWriteSid(path)`。每个规范目录只授予其自身的 SID,不要将同一 SID 授予多个目录。
+3. 对于调用方管理授权的 runner 调用,按去重后先工作区、再额外根目录的顺序,为每个规范目录重复一次 `--write-sid`,并保留 `--temp-write-sid`。无 agent 调用省略这两类 SID 标志。
+4. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。
diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml
index 8f842f5e52..69844719dd 100644
--- a/packages/sandbox/sandbox-local/README.i18n.yaml
+++ b/packages/sandbox/sandbox-local/README.i18n.yaml
@@ -36,8 +36,8 @@
en: f0c44248c2ca6acc
zh: 554e9f706671d222
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles:
- en: bd441e16c283095e
- zh: eef437f45922e174
+ en: c167bc9f232d1103
+ zh: a2172af886eff752
/deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects:
en: 62adfc9229c656cc
zh: 11b1534b6d61e5de
diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md
index 5fd8d86984..481829b0d2 100644
--- a/packages/sandbox/sandbox-local/README.md
+++ b/packages/sandbox/sandbox-local/README.md
@@ -80,7 +80,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`).
-The Windows rung derives a deterministic write SID from each sorted, deduplicated set of canonical writable directories and reuses standing ACEs only for that exact set, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
+The Windows rung derives one deterministic write SID per canonical writable directory and reuses its standing ACE across permission combinations, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child.
With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources.
diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md
index b20edc2aaf..65d301dfd0 100644
--- a/packages/sandbox/sandbox-local/README.zh.md
+++ b/packages/sandbox/sandbox-local/README.zh.md
@@ -80,7 +80,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名
Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。
-Windows 档从排序、去重后的规范可写目录集合派生确定性写入 SID,并仅为完全相同的集合复用常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
+Windows 档为每个规范可写目录派生一个确定性写入 SID,并在不同权限组合之间复用其常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。
当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。
diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts
index b9eeb61946..c24e175548 100644
--- a/packages/sandbox/sandbox-local/src/index.ts
+++ b/packages/sandbox/sandbox-local/src/index.ts
@@ -5,9 +5,9 @@
* classification facts. Missing or unusable confinement fails closed rather
* than returning the original argv.
*
- * The windows-acl rung owns standing grants keyed by the complete canonical
- * writable-directory set. Each set has a deterministic write SID; executions
- * with different sets never share that capability. Every live session/workspace
+ * The windows-acl rung owns standing grants keyed by canonical directory.
+ * Each directory has a deterministic write SID, and an execution carries only
+ * the directory SIDs its current policy permits. Every live session/workspace
* pair has a separate random private temp directory and revocable temp SID.
* The runner receives both identities and does not manage their DACLs.
* Enforcement remains partial because hard links alias file objects, reads
@@ -31,7 +31,7 @@ import z from '@deepseek-ai/schemastery'
import { SandboxProvider, SandboxUnavailableError, canonicalPath, workspaceWritableRoots } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { SessionId } from '@deepseek-ai/dsh-session'
-import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
+import { AclWriteGrant, assertPrivateTempDisjoint, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
@@ -259,7 +259,7 @@ export class LocalSandboxProvider extends SandboxProvider {
private readonly probeTimeoutMs: number
/** Cached chain verdict; undefined until the first confined wrap needs it. */
private selectedRunner: SelectedRunner | 'unavailable' | undefined
- /** Standing grants keyed by the sorted canonical directory set; ACEs outlive the provider. */
+ /** Standing grants keyed by canonical directory; ACEs outlive the provider. */
private readonly directoryGrants = new Map()
/** Revocable private temp grants per live session/workspace pair. */
private readonly tempCapabilities = new Map()
@@ -370,13 +370,13 @@ export class LocalSandboxProvider extends SandboxProvider {
'--temp', temp.dir,
'--mode', policy.mode,
...extraArgs,
- '--write-sid', workspaceWriteSid(policy.workspaceRoot, roots),
+ ...roots.flatMap(root => ['--write-sid', directoryWriteSid(root)]),
'--temp-write-sid', temp.writeSid,
]
}
/**
- * Resolve or materialize the exact writable-directory set's standing ACEs.
+ * Resolve or materialize each permitted directory's standing ACE.
* Private temp capabilities remain scoped to the session/workspace pair;
* changing directory sets does not revoke grants held by running processes.
* Failed temp materialization revokes its grant and removes its directory.
@@ -390,24 +390,24 @@ export class LocalSandboxProvider extends SandboxProvider {
const key = JSON.stringify([String(sessionId), workspaceRoot])
const existing = this.tempCapabilities.get(key)
if (existing !== undefined) assertPrivateTempDisjoint(roots, existing.dir)
- const writeSid = workspaceWriteSid(workspaceRoot, roots)
- const grantKey = JSON.stringify([...roots].sort())
- const cachedGrant = this.directoryGrants.get(grantKey)
- const directoryGrant = cachedGrant ?? AclWriteGrant.create(writeSid)
- try {
- for (const root of roots) directoryGrant.add(root, true)
- } catch (error) {
- if (cachedGrant !== undefined) throw error
- // Standing ACEs remain reusable; only the failed entry's allocations
- // are released. A later execution retries the complete directory set.
+ for (const root of roots) {
+ const cachedGrant = this.directoryGrants.get(root)
+ const directoryGrant = cachedGrant ?? AclWriteGrant.create(directoryWriteSid(root))
try {
- directoryGrant.dispose()
- } catch (cleanupError) {
- throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed')
+ directoryGrant.add(root, true)
+ } catch (error) {
+ if (cachedGrant !== undefined) throw error
+ // Standing ACEs remain reusable; only the failed entry's allocations
+ // are released. A later execution retries this directory's grant.
+ try {
+ directoryGrant.dispose()
+ } catch (cleanupError) {
+ throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl directory grant failed and its cleanup also failed')
+ }
+ throw error
}
- throw error
+ this.directoryGrants.set(root, directoryGrant)
}
- this.directoryGrants.set(grantKey, directoryGrant)
if (existing !== undefined) return existing
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
const tempSid = tempWriteSid(tempDir)
diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
index 63020397cf..6ed6d2a7d3 100644
--- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
+++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
@@ -1,6 +1,6 @@
/**
* windows-acl grant ownership through the real LocalSandboxProvider: one
- * standing capability per workspace plus one random, distinct, revocable
+ * standing capability per directory plus one random, distinct, revocable
* temp capability per live session/workspace pair. The Win32 grant surface
* is mocked; native access checks live in sandbox-windows-acl's runner suite.
*/
@@ -13,7 +13,7 @@ import { Context } from '@deepseek-ai/cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
-import { workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
+import { directoryWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
const mockState = vi.hoisted(() => ({
@@ -85,6 +85,10 @@ function flag(argv: readonly string[], name: string): string | undefined {
return index < 0 ? undefined : argv[index + 1]
}
+function flags(argv: readonly string[], name: string): string[] {
+ return argv.filter((_value, index) => index > 0 && argv[index - 1] === name)
+}
+
describe('windows-acl write grants (LocalSandboxProvider)', () => {
const scratch: string[] = []
@@ -132,19 +136,19 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
expect(tempDir).toBeDefined()
expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u)
expect(tempSid).toBe(`TEMP:${tempDir}`)
- expect(tempSid).not.toBe(workspaceWriteSid(ws))
+ expect(tempSid).not.toBe(directoryWriteSid(ws))
expect(confined.argv).toEqual([
'node', 'windows-acl-runner.js',
'--workspace', ws,
'--temp', tempDir,
'--mode', 'workspace-write',
- '--write-sid', workspaceWriteSid(ws),
+ '--write-sid', directoryWriteSid(ws),
'--temp-write-sid', tempSid,
'--',
'pwsh', '/Command', 'x',
])
expect(mockState.grants).toEqual([
- expect.objectContaining({ writeSid: workspaceWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }),
+ expect.objectContaining({ writeSid: directoryWriteSid(ws), added: [{ path: ws, standing: true }], disposed: false }),
expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }),
])
expect(existsSync(tempDir ?? '')).toBe(true)
@@ -160,38 +164,45 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
}
})
- it('selects exact directory-set grants while retaining session-private temp capabilities', async () => {
+ it('reuses each directory grant across changing sets and workspaces without granting sibling roots', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
const memory = workspaceRoot()
const cache = workspaceRoot()
- scratch.push(ws, memory, cache)
+ const otherWorkspace = workspaceRoot()
+ scratch.push(ws, memory, cache, otherWorkspace)
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sets') }
const base = await sandbox.confine(['true'], policy)
const expanded = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [memory, cache] })
- const expandedSid = workspaceWriteSid(ws, [memory, cache])
- expect(flag(expanded.argv, '--write-sid')).toBe(expandedSid)
- expect(expandedSid).not.toBe(flag(base.argv, '--write-sid'))
+ expect(flags(expanded.argv, '--write-sid')).toEqual([ws, memory, cache].map(directoryWriteSid))
expect(flag(expanded.argv, '--temp')).toBe(flag(base.argv, '--temp'))
expect(flag(expanded.argv, '--temp-write-sid')).toBe(flag(base.argv, '--temp-write-sid'))
- expect(mockState.grants.filter(grant => grant.writeSid === expandedSid)).toEqual([
- expect.objectContaining({ added: [ws, memory, cache].map(path => ({ path, standing: true })), disposed: false }),
- ])
+ expect(mockState.grants).toHaveLength(4)
+ const memoryGrant = mockState.grants.find(grant => grant.writeSid === directoryWriteSid(memory))!
const reordered = await sandbox.confine(['true'], { ...policy, additionalWritableRoots: [cache, memory, cache, ws] })
- expect(flag(reordered.argv, '--write-sid')).toBe(expandedSid)
- expect(mockState.grants).toHaveLength(3)
+ expect(flags(reordered.argv, '--write-sid')).toEqual([ws, cache, memory].map(directoryWriteSid))
+ expect(mockState.grants).toHaveLength(4)
expect((await sandbox.confine(['true'], policy)).argv).toEqual(base.argv)
expect(mockState.grants.every(grant => !grant.disposed)).toBe(true)
const sibling = await sandbox.confine(['true'], { ...policy, sessionId: SessionId('sibling') })
- expect(flag(sibling.argv, '--write-sid')).toBe(flag(base.argv, '--write-sid'))
+ expect(flags(sibling.argv, '--write-sid')).toEqual([directoryWriteSid(ws)])
expect(flag(sibling.argv, '--temp-write-sid')).not.toBe(flag(base.argv, '--temp-write-sid'))
- expect(mockState.grants).toHaveLength(4)
+ const other = await sandbox.confine(['true'], {
+ ...policy, sessionId: SessionId('other'), workspaceRoot: otherWorkspace, additionalWritableRoots: [memory],
+ })
+ expect(flags(other.argv, '--write-sid')).toEqual([otherWorkspace, memory].map(directoryWriteSid))
+ expect(mockState.grants).toHaveLength(7)
+ expect(mockState.grants.filter(grant => grant.writeSid === directoryWriteSid(memory))).toEqual([memoryGrant])
+ for (const root of [ws, memory, cache, otherWorkspace]) {
+ const grant = mockState.grants.find(candidate => candidate.writeSid === directoryWriteSid(root))!
+ expect(grant.added.every(added => added.path === root && added.standing)).toBe(true)
+ }
} finally { await fiber.dispose(); cleanup() }
})
- it('reapplies a cached set grant when an extra directory is recreated', async () => {
+ it('reapplies a cached directory grant when an extra directory is recreated', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
@@ -201,18 +212,18 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('recreated'), additionalWritableRoots: [extra],
}
const first = await sandbox.confine(['true'], policy)
- const directoryGrant = mockState.grants[0]!
+ const directoryGrant = mockState.grants[1]!
rmSync(extra, { recursive: true })
mkdirSync(extra)
const second = await sandbox.confine(['true'], policy)
expect(second.argv).toEqual(first.argv)
- expect(mockState.grants).toHaveLength(2)
- expect(mockState.grants[0]).toBe(directoryGrant)
- expect(directoryGrant.added).toEqual([ws, extra, ws, extra].map(path => ({ path, standing: true })))
+ expect(mockState.grants).toHaveLength(3)
+ expect(mockState.grants[1]).toBe(directoryGrant)
+ expect(directoryGrant.added).toEqual([extra, extra].map(path => ({ path, standing: true })))
} finally { await fiber.dispose(); cleanup() }
})
- it('retries every directory after a partial set grant fails', async () => {
+ it('retains successful directory grants and retries the directory that failed', async () => {
const { sandbox, fiber } = await setup()
try {
const ws = workspaceRoot()
@@ -224,23 +235,20 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
mockState.addFailure = new Error('extra grant failed')
mockState.addFailurePath = extra
await expect(sandbox.confine(['true'], policy)).rejects.toThrow('extra grant failed')
- expect(mockState.grants).toHaveLength(1)
- expect(mockState.grants[0]).toMatchObject({
- added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: true,
- })
+ expect(mockState.grants).toHaveLength(2)
+ expect(mockState.grants[0]).toMatchObject({ added: [{ path: ws, standing: true }], disposed: false })
+ expect(mockState.grants[1]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: true })
mockState.addFailure = undefined
await sandbox.confine(['true'], policy)
- expect(mockState.grants).toHaveLength(3)
- expect(mockState.grants[1]).toMatchObject({
- added: [{ path: ws, standing: true }, { path: extra, standing: true }], disposed: false,
- })
+ expect(mockState.grants).toHaveLength(4)
+ expect(mockState.grants[2]).toMatchObject({ added: [{ path: extra, standing: true }], disposed: false })
mockState.addFailure = new Error('cached grant refresh failed')
await expect(sandbox.confine(['true'], policy)).rejects.toThrow('cached grant refresh failed')
- expect(mockState.grants).toHaveLength(3)
- expect(mockState.grants[1]!.disposed).toBe(false)
+ expect(mockState.grants).toHaveLength(4)
+ expect(mockState.grants[2]!.disposed).toBe(false)
mockState.addFailure = undefined
await sandbox.confine(['true'], policy)
- expect(mockState.grants).toHaveLength(3)
+ expect(mockState.grants).toHaveLength(4)
} finally { await fiber.dispose(); cleanup() }
})
@@ -277,7 +285,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => {
expect(mockState.grants).toHaveLength(0)
const upgraded = await sandbox.confine(['true'], workspaceWrite)
- expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(workspaceWriteSid(ws))
+ expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(directoryWriteSid(ws))
expect(mockState.grants).toHaveLength(2)
await sandbox.confine(['true'], readOnly)
expect(mockState.grants).toHaveLength(2)
diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
index e778ee1934..aa6a067db2 100644
--- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
+++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
@@ -33,14 +33,14 @@
en: c2cde86c85c2c619
zh: a29a114e591f9d3f
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism:
- en: 97ef643f608ae7d8
- zh: bf4eb7941f4573f6
+ en: 3981364f27a7ce01
+ zh: 1ac67fa0481a63d4
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists:
- en: 1a4ed5c06a5be74d
- zh: 053cb81bd76260ed
+ en: 5a0d49485508f4a8
+ zh: f332d2d2ba2d8170
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner:
- en: de2dce4b18f77dad
- zh: bbe7eef92b441364
+ en: 8515514d01b74c47
+ zh: f0c24d2b3bd3abfd
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries:
en: ad02570eae90037f
zh: fd78c2318de18dda
diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md
index 9344325c43..ce151e9269 100644
--- a/packages/sandbox/sandbox-windows-acl/README.md
+++ b/packages/sandbox/sandbox-windows-acl/README.md
@@ -39,7 +39,7 @@ Choose it for Windows compositions that confine subprocess file effects under `r
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
+import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
const workspaceRoot = process.cwd()
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
@@ -50,7 +50,6 @@ const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
const sandbox = new AclSandbox({
writableDirs: [workspaceRoot],
tempDir,
- writeSid: workspaceWriteSid(workspaceRoot),
tempWriteSid: tempWriteSid(tempDir),
mode: 'workspace-write',
})
@@ -95,11 +94,11 @@ This section explains the restricted-token mechanism, the token lists, the runne
### Mechanism
-The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `workspaceWriteSid(workspaceRoot, additionalWritableRoots)` hashes the sorted, deduplicated canonical directory set with unambiguous JSON encoding; a workspace-only set preserves its existing SID. Each execution resolves or materializes standing ACEs for that exact set. Adding or removing a directory selects a different SID for new processes, while running processes keep their launch token. Equal sets reuse their standing security descriptor edits through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
+The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
### Modes and token lists
-`workspace-write` (logon SID, Everyone, directory-set SID, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
+`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working.
@@ -110,10 +109,10 @@ Authenticated Users is absent from both lists — the WMI namespace security che
The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `dsh-sandbox-local` spawns in place of the caller's command, with the same architecture as bwrap/landlock-run/sandbox-exec. The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through, wraps the child in a `KILL_ON_JOB_CLOSE` job, mirrors the child's exit code, and revokes its self-managed temp grant on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's runner-failure rules match that signature.
```sh
-node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] --
+node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] --
```
-The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry for the exact directory set, rechecking each root's ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing both as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies the directory-set SID against all selected roots and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without the pair, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
+The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation.
diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md
index 2f11123c00..af080d999b 100644
--- a/packages/sandbox/sandbox-windows-acl/README.zh.md
+++ b/packages/sandbox/sandbox-windows-acl/README.zh.md
@@ -39,7 +39,7 @@ kind: "package-library"
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
+import { AclSandbox, tempWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
const workspaceRoot = process.cwd()
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
@@ -50,7 +50,6 @@ const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
const sandbox = new AclSandbox({
writableDirs: [workspaceRoot],
tempDir,
- writeSid: workspaceWriteSid(workspaceRoot),
tempWriteSid: tempWriteSid(tempDir),
mode: 'workspace-write',
})
@@ -97,11 +96,11 @@ rmSync(tempDir, { recursive: true, force: true })
### 机制
-调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`workspaceWriteSid(workspaceRoot, additionalWritableRoots)` 对排序、去重后的规范目录集合使用无歧义的 JSON 编码求哈希;仅含工作区的集合保留现有 SID。每次执行查找或物化完全匹配该集合的常驻 ACE。增加或移除目录会为新进程选择不同的 SID,运行中的进程保留启动令牌。相同集合通过精确 ACE/精确拒绝/精确标签跳过,复用常驻安全描述符改动。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
+调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
### 模式与令牌列表
-`workspace-write`(登录 SID、Everyone、目录集合 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
+`workspace-write`(登录 SID、Everyone、目录 SID、临时 SID)为选定的可写目录与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。
NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。
@@ -112,10 +111,10 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全
面向 seam 的形态是 runner 入口(`./runner`):`dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构。runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传,把子进程包进 `KILL_ON_JOB_CLOSE` job,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 runner 失败规则匹配该签名。
```sh
-node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid --temp-write-sid ] --
+node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] --
```
-seam 通过重复的 `--writable-root` 参数传入额外根目录,并为完全相同的目录集合复用授权项,每次执行前重新检查各根目录的 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 根据全部选定根目录验证目录集合 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。
+seam 通过重复的 `--writable-root` 参数传入额外根目录,并按规范目录复用授权项,每次执行前重新检查其 ACE,使重新创建的目录也获得授权,再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID,为每个目录传入一个 `--write-sid`,并同时传入 `--temp-write-sid`;runner 按去重后先工作区、再额外根目录的顺序验证目录 SID,并根据私有路径验证临时 SID,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾。如果不带这些 SID 标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过重新传播。可写根目录若等于或包含临时根目录,会在任何授权前被拒绝;私有临时目录必须与每个可写根目录互不重叠。
启动时若带有 subprocess 控制标记,runner 会通过受限子进程的 CRT 启动表转发 fd 7,并在 spawn 后立即关闭自身副本。可选的 `controlFileDescriptor: 7` 输入要求 `stdio: 'inherit'`;在管道 stdio 下请求它会在进程创建前失败。
diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts
index 2316b0f6ed..e3f0423530 100644
--- a/packages/sandbox/sandbox-windows-acl/src/grant.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts
@@ -1,7 +1,7 @@
/**
* Server-side write-grant materialization. The sandbox seam holds one
- * standing grant per writable-directory set and one revocable temp grant per
- * live session/workspace pair. Directory-set identities survive by deterministic
+ * standing grant per writable directory and one revocable temp grant per
+ * live session/workspace pair. Directory identities survive by deterministic
* derivation and their standing ACE; temp identities derive from random
* private paths and are deliberately new after a restart.
*
diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts
index 9413dc60c2..6b5b51910f 100644
--- a/packages/sandbox/sandbox-windows-acl/src/index.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/index.ts
@@ -13,11 +13,12 @@
* for the complete boundary). The intersection covers only the object's own
* access check, so the token is also lowered to Low integrity and every
* granted directory carries a Low no-write-up label and the ambient-delete
- * deny the `acl` module documents. The write SID identifies the complete
- * canonical writable-directory set ({@link workspaceWriteSid}). Standing
- * ACEs are reusable across calls and sessions with that exact set. Each
- * private temp directory instead receives its own SID, so sibling sessions
- * cannot use a shared directory-set capability to enter one another's temp
+ * deny the `acl` module documents. Each canonical writable directory has a
+ * stable SID ({@link directoryWriteSid}) granted only to that directory.
+ * An execution carries the SIDs of its allowed directories; standing ACEs
+ * are reusable across sessions and permission combinations. Each private
+ * temp directory instead receives its own SID, so sibling sessions cannot
+ * use a shared directory capability to enter one another's temp
* trees. Unlike the POC, every API failure throws with the API
* name and exact Win32 code; a child is NEVER spawned unrestricted.
*
@@ -41,7 +42,7 @@
* @module @deepseek-ai/dsh-sandbox-windows-acl
*/
-import { existsSync, statSync } from 'node:fs'
+import { existsSync, realpathSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
import { Win32Error } from '@deepseek-ai/dsh-win32-process'
@@ -55,7 +56,9 @@ import * as abi from './win32-abi.ts'
export { AclWriteGrant } from './grant.ts'
export { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from './path-boundary.ts'
-export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
+import { directoryWriteSid } from './workspace-sid.ts'
+
+export { tempWriteSid, directoryWriteSid } from './workspace-sid.ts'
export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts'
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
export interface AclSandboxOptions {
@@ -67,18 +70,10 @@ export interface AclSandboxOptions {
* root is never an implicit grant. Read-only accepts only null/undefined.
*/
tempDir?: string | null
- /**
- * The write SID forming the workspace-write allowlist: REQUIRED under
- * workspace-write, ignored (and must be absent) under read-only. Callers
- * derive it from the complete canonical writable-directory set via
- * {@link workspaceWriteSid}. Standing ACEs outlive sandbox instances and
- * are reusable by later executions carrying that exact set identity.
- */
- writeSid?: string
/**
* The private temp directory's write SID. Required whenever
* workspace-write grants a temp directory, absent otherwise. It must be
- * distinct from {@link writeSid}, so sibling sessions sharing a workspace
+ * distinct from every directory write SID, so sibling sessions sharing a workspace
* cannot use the standing workspace capability in one another's temp tree.
*/
tempWriteSid?: string
@@ -160,9 +155,9 @@ function freeSidBestEffort(
*/
export class AclSandbox {
/** Absolute writable directories (constructor-validated). */
- readonly writableDirs: string[]
- /** The directory-set SID whose ACEs form the writable allowlist. */
- readonly writeSid: string | undefined
+ readonly writableDirs: readonly string[]
+ /** The independently derived write SIDs, in the same order as writableDirs; empty under read-only. */
+ readonly writeSids: readonly string[]
/** The private temp directory's write SID (workspace-write with temp only). */
readonly tempWriteSid: string | undefined
/** The file-effect mode — the restricted token's restricting-SID list selection. */
@@ -172,7 +167,7 @@ export class AclSandbox {
private tempDirResolved: string | null | undefined
private api: Win32Bindings | undefined
private token: NativePtr | undefined
- private writeSidPtr: NativePtr | undefined
+ private readonly directorySidPtrs = new Map()
private tempWriteSidPtr: NativePtr | undefined
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */
private sidAllocations: NativePtr[] = []
@@ -181,26 +176,23 @@ export class AclSandbox {
constructor(options: AclSandboxOptions) {
this.mode = options.mode
this.manageDacls = options.manageDacls ?? true
- this.writableDirs = options.writableDirs.map((directory) => {
+ this.writableDirs = [...new Set(options.writableDirs.map((directory) => {
const absolute = resolve(directory)
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`)
}
- return absolute
- })
+ return realpathSync.native(absolute)
+ }))]
this.tempDirOption = options.tempDir
- this.writeSid = options.writeSid
+ this.writeSids = this.mode === 'workspace-write' ? this.writableDirs.map(directoryWriteSid) : []
this.tempWriteSid = options.tempWriteSid
- if (this.mode === 'workspace-write' && this.writeSid === undefined) {
- throw new Error('AclSandbox workspace-write requires a write SID — derive it from the writable directories via workspaceWriteSid()')
- }
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
}
if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) {
throw new Error('AclSandbox read-only does not accept a temp directory')
}
- if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) {
+ if (this.mode === 'read-only' && this.tempWriteSid !== undefined) {
throw new Error('AclSandbox read-only does not accept write SIDs')
}
if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) {
@@ -209,7 +201,7 @@ export class AclSandbox {
if (this.tempDirOption === null && this.tempWriteSid !== undefined) {
throw new Error('AclSandbox temp write SID requires a temp directory')
}
- if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) {
+ if (this.tempWriteSid !== undefined && this.writeSids.includes(this.tempWriteSid)) {
throw new Error('AclSandbox workspace and temp write SIDs must be distinct')
}
}
@@ -236,7 +228,11 @@ export class AclSandbox {
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid)
return parsedSid
}
- this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid)
+ if (this.mode === 'workspace-write') {
+ for (const directory of this.writableDirs) {
+ this.directorySidPtrs.set(directory, parseSid(directoryWriteSid(directory)))
+ }
+ }
this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid)
const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption
@@ -254,7 +250,7 @@ export class AclSandbox {
// manageDacls: false — the caller (the sandbox seam's grant) already
// materialized the ACEs; this instance must neither add nor remove any.
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
- // directory-set reuse cache — dispose() never revokes them, or the next
+ // per-directory reuse cache — dispose() never revokes them, or the next
// provision would re-propagate the whole tree) and the temp ACE is
// REVOCABLE (dispose() removes it before the private directory is
// deleted; the ambient temp root is never granted).
@@ -264,9 +260,9 @@ export class AclSandbox {
this.sidAllocations.push(lowLabelSid, worldSid)
if (this.manageDacls) {
- if (this.writeSidPtr !== undefined) {
- for (const path of this.writableDirs) {
- grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid)
+ if (this.mode === 'workspace-write') {
+ for (const [path, sidPtr] of this.directorySidPtrs) {
+ grantWrite(api, path, sidPtr, lowLabelSid, worldSid)
}
if (tempDir !== null && this.tempWriteSidPtr !== undefined) {
// Record BEFORE granting: grantWrite can throw after a successful
@@ -279,7 +275,8 @@ export class AclSandbox {
}
const logonSid = findLogonSid(api, currentToken)
this.sidAllocations.push(logonSid)
- const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined)
+ const writeSids = [...this.directorySidPtrs.values()]
+ if (this.tempWriteSidPtr !== undefined) writeSids.push(this.tempWriteSidPtr)
restrictedToken = createRestrictedToken(
api, currentToken, logonSid, writeSids,
{ world: worldSid },
@@ -299,7 +296,7 @@ export class AclSandbox {
// DACL passes pass-2. Choosing the temp SID prevents default-DACL
// objects in one session's temp tree from acquiring the shared
// workspace capability.
- setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid)
+ setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.directorySidPtrs.values().next().value ?? worldSid)
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
currentTokenOpen = false
this.api = api
@@ -322,14 +319,13 @@ export class AclSandbox {
cleanupFailures.push(cleanupError)
}
}
- for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
- freeSidBestEffort(api, sidPtr, label, cleanupFailures)
- }
+ for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', cleanupFailures)
+ freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', cleanupFailures)
for (const sidPtr of this.sidAllocations.splice(0)) {
freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures)
}
this.token = undefined
- this.writeSidPtr = undefined
+ this.directorySidPtrs.clear()
this.tempWriteSidPtr = undefined
this.tempDirResolved = undefined
this.grantedPaths = []
@@ -417,9 +413,8 @@ export class AclSandbox {
}
}
}
- for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
- freeSidBestEffort(api, sidPtr, label, failures)
- }
+ for (const sidPtr of this.directorySidPtrs.values()) freeSidBestEffort(api, sidPtr, 'directory write SID', failures)
+ freeSidBestEffort(api, this.tempWriteSidPtr, 'temp write SID', failures)
const token = this.token
/* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
has its token; the guard mirrors the write-SID guard. */
@@ -435,7 +430,7 @@ export class AclSandbox {
}
this.api = undefined
this.token = undefined
- this.writeSidPtr = undefined
+ this.directorySidPtrs.clear()
this.tempWriteSidPtr = undefined
this.grantedPaths = []
if (failures.length > 0) {
diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts
index 155c5a5c82..62cb1b40f3 100644
--- a/packages/sandbox/sandbox-windows-acl/src/runner.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts
@@ -1,7 +1,7 @@
/**
* The windows-acl confinement runner: the argv-prefix wrapper the sandbox
* seam spawns in place of the caller's command. It creates the
- * WRITE_RESTRICTED token with the directory-set write-SID allowlist, spawns the
+ * WRITE_RESTRICTED token with the per-directory write-SID allowlist, spawns the
* wrapped argv under it with the CALLER'S stdio inherited (bytes flow
* straight through), mirrors the child's exit code, and revokes its temp
* grant on exit (workspace ACEs stay standing as the reuse cache).
@@ -10,11 +10,10 @@
* keep the same contract):
* [node, runner.js, '--workspace', , '--temp', ,
* '--mode', , ['--writable-root', ]...,
- * ['--write-sid', ,
- * '--temp-write-sid', ], '--', ]
+ * ['--write-sid', ]..., ['--temp-write-sid', ], '--', ]
*
* Modes:
- * - workspace-write: the writable-directory set and private temp carry distinct
+ * - workspace-write: each writable directory and private temp carry distinct
* capability-SID Write grants; other ACL-addressable writes are denied
* except for the documented Everyone and hard-link boundaries.
* - read-only: no capability-SID grants; the restricting list carries no
@@ -24,11 +23,11 @@
* Public tree writes are denied); the two lists share the keep-alive group
* (logon SID, EVERYONE) and differ only by the capabilities.
*
- * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
- * CALLER has already materialized distinct workspace and private-temp ACEs
+ * Repeated `--write-sid` plus `--temp-write-sid`: the seam's grant contract — the
+ * CALLER has already materialized per-directory and private-temp ACEs
* and owns their revocation, so the runner neither grants nor revokes
- * (`manageDacls: false`). Both values are checked against their owning directory set or private path.
- * Without the pair (standalone/agentless use), workspace-write treats
+ * (`manageDacls: false`). Each SID is checked against its owning directory path.
+ * Without these SID flags (standalone/agentless use), workspace-write treats
* `--temp` as a ROOT, creates a random private child directory, derives its
* own temp SID, and removes that directory after the child exits. In both
* flows the runner rewrites TMP/TEMP in its OWN environment to the private
@@ -45,12 +44,12 @@
*/
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
-import { closeSync, existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
+import { closeSync, existsSync, mkdtempSync, realpathSync, rmSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { win32 } from './ffi.ts'
import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts'
-import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
+import { tempWriteSid, directoryWriteSid } from './workspace-sid.ts'
const RUNNER_SIGNATURE = 'windows-acl-run'
const RUNNER_FAILURE_EXIT = 127
@@ -68,7 +67,7 @@ interface ParsedArgs {
temp: string
mode: 'read-only' | 'workspace-write'
writableRoots: string[]
- writeSid: string | undefined
+ writeSids: string[]
tempWriteSid: string | undefined
command: string
args: string[]
@@ -79,7 +78,7 @@ function parseArgs(raw: string[]): ParsedArgs {
let workspace: string | undefined
let temp: string | undefined
let mode: string | undefined
- let writeSid: string | undefined
+ const writeSids: string[] = []
let parsedTempWriteSid: string | undefined
let index = 0
for (; index < raw.length; index++) {
@@ -96,7 +95,7 @@ function parseArgs(raw: string[]): ParsedArgs {
case '--writable-root': writableRoots.push(value); break
case '--temp': temp = value; break
case '--mode': mode = value; break
- case '--write-sid': writeSid = value; break
+ case '--write-sid': writeSids.push(value); break
case '--temp-write-sid': parsedTempWriteSid = value; break
default: fail(`unknown argument: ${token}`)
}
@@ -107,7 +106,7 @@ function parseArgs(raw: string[]): ParsedArgs {
const argv = raw.slice(index)
const command = argv[0]
if (command === undefined) fail('missing command after --')
- return { workspace, temp, mode, writableRoots, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
+ return { workspace, temp, mode, writableRoots, writeSids, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
}
function requireDirectory(label: string, path: string): void {
@@ -123,16 +122,16 @@ async function main(): Promise {
requireDirectory('--workspace', parsed.workspace)
requireDirectory('--temp', parsed.temp)
for (const root of parsed.writableRoots) requireDirectory('--writable-root', root)
- const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots])]
+ const writableDirs = [...new Set([parsed.workspace, ...parsed.writableRoots].map(directory => realpathSync.native(directory)))]
- const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined
+ const seamManaged = parsed.writeSids.length > 0 || parsed.tempWriteSid !== undefined
if (parsed.mode === 'read-only' && parsed.writableRoots.length > 0) {
fail('read-only does not accept --writable-root')
}
if (parsed.mode === 'read-only' && seamManaged) {
fail('read-only does not accept --write-sid or --temp-write-sid')
}
- if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) {
+ if (parsed.mode === 'workspace-write' && (parsed.writeSids.length === 0) !== (parsed.tempWriteSid === undefined)) {
fail('workspace-write requires --write-sid and --temp-write-sid together')
}
if (parsed.mode === 'workspace-write') {
@@ -152,12 +151,13 @@ async function main(): Promise {
let initialized = false
try {
let privateTempDir: string | null = null
- let writeSid: string | undefined
let privateTempSid: string | undefined
if (parsed.mode === 'workspace-write') {
- writeSid = workspaceWriteSid(parsed.workspace, parsed.writableRoots)
if (seamManaged) {
- if (parsed.writeSid !== writeSid) fail('--write-sid does not match writable directories')
+ if (parsed.writeSids.length !== writableDirs.length
+ || writableDirs.some((directory, index) => parsed.writeSids[index] !== directoryWriteSid(directory))) {
+ fail('--write-sid must match each writable directory in order')
+ }
privateTempDir = parsed.temp
privateTempSid = tempWriteSid(privateTempDir)
if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp')
@@ -171,7 +171,6 @@ async function main(): Promise {
writableDirs: parsed.mode === 'workspace-write' ? writableDirs : [],
tempDir: privateTempDir,
mode: parsed.mode,
- ...writeSid === undefined ? {} : { writeSid },
...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid },
manageDacls: !seamManaged,
})
diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts
index 25e06ae3f4..06ad9690f0 100644
--- a/packages/sandbox/sandbox-windows-acl/src/token.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/token.ts
@@ -182,7 +182,7 @@ export interface RestrictingSidSet {
* Create the write-restricted token with the mode-selected restricting list
* (verified on Win11 26200, see the POC-worktree restrict-variant harness):
* - read-only: [logon SID, EVERYONE]
- * - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID]
+ * - workspace-write: [logon SID, EVERYONE, ...directory SIDs, optional temp SID]
*
* The logon SID + EVERYONE keep-alive group is shared by both modes: early
* DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee —
@@ -207,7 +207,7 @@ export interface RestrictingSidSet {
* @param api - the binding table.
* @param currentToken - the process token to restrict.
* @param logonSid - the copied logon session SID.
- * @param writeSids - the distinct write SIDs forming the workspace and
+ * @param writeSids - the distinct write SIDs forming the directory and
* optional temp allowlists (workspace-write only; empty under read-only).
* @param known - the well-known SIDs entering the restricting list.
* @param mode - selects the restricting list (workspace-write adds the capability SIDs).
diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts
index 58c9c0dd4e..1a446e5cbf 100644
--- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts
+++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts
@@ -1,24 +1,19 @@
/**
- * Deterministic write identities for canonical writable-directory sets and
- * separate identities for private temp directories. Standing directory ACEs
- * are reusable only by executions carrying the same complete set identity.
+ * Stable write identities for individual canonical directories, with separate
+ * identities for private temp directories.
* @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid
*/
import { createHash } from 'node:crypto'
/**
- * Derive the write SID for a workspace and its additional writable roots.
- * Order and duplicate entries do not affect the identity. A workspace-only
- * set retains its existing SID; larger sets use unambiguous JSON encoding.
- * @param workspaceRoot - the canonical workspace path.
- * @param additionalWritableRoots - canonical extra directories; defaults to none.
- * @returns the deterministic SDDL string for the complete directory set.
+ * Derive one directory's write SID. Grant this SID only on that directory;
+ * executions carry the SIDs of all directories they are allowed to write.
+ * @param directory - the canonical directory path.
+ * @returns the deterministic SDDL string, preserving existing workspace identities.
*/
-export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots: readonly string[] = []): string {
- const roots = [...new Set([workspaceRoot, ...additionalWritableRoots])].sort()
- const identity = roots.length === 1 ? workspaceRoot : JSON.stringify(roots)
- const digest = createHash('sha256').update(identity, 'utf8').digest()
+export function directoryWriteSid(directory: string): string {
+ const digest = createHash('sha256').update(directory, 'utf8').digest()
const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
return `S-1-4-${first}-${second}`
@@ -27,7 +22,7 @@ export function workspaceWriteSid(workspaceRoot: string, additionalWritableRoots
/**
* Derive one private temp directory's write SID. The random directory path
* is the capability identity; a fixed third subauthority domain-separates
- * the result from every two-subauthority workspace SID.
+ * the result from every two-subauthority directory SID.
* @param tempDir - the private temp directory's absolute path.
* @returns the SDDL string form.
*/
diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts
index 3bf86f4d43..255f15c5dc 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts
@@ -9,14 +9,14 @@
* whose per-test lock file is removed in cleanup.
*/
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts'
-import { AclSandbox } from '../src/index.ts'
+import { AclSandbox, directoryWriteSid } from '../src/index.ts'
import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts'
import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
@@ -209,7 +209,7 @@ describe.skipIf(!isWin32)('ACL editing', () => {
})
function scratch(): string {
- const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-'))
+ const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')))
scratchDirs.push(dir)
return dir
}
@@ -364,11 +364,11 @@ describe.skipIf(!isWin32)('ACL editing', () => {
}
})
- it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => {
+ it('interleaved sandbox instances reuse one standing directory ACE', async () => {
const api = await win32()
const dir = scratch()
- const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' })
- const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })
+ const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
+ const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
await sandboxA.init()
await sandboxB.init()
// Workspace ACEs are STANDING: dispose frees the instance's SID
@@ -377,8 +377,29 @@ describe.skipIf(!isWin32)('ACL editing', () => {
sandboxA.dispose()
sandboxB.dispose()
const aces = readDirectAces(api, dir)
- expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true)
- expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true)
+ expect(aces.filter(ace => ace.sid === directoryWriteSid(dir))).toHaveLength(1)
+ })
+
+ it('shares a directory capability across sandbox sets without granting it on their other directories', async () => {
+ const api = await win32()
+ const workspaceA = scratch()
+ const workspaceB = scratch()
+ const memory = scratch()
+ const sandboxA = new AclSandbox({ writableDirs: [workspaceA, memory], tempDir: null, mode: 'workspace-write' })
+ const sandboxB = new AclSandbox({ writableDirs: [workspaceB, memory], tempDir: null, mode: 'workspace-write' })
+ try {
+ await sandboxA.init()
+ await sandboxB.init()
+ const dirs = [workspaceA, workspaceB, memory]
+ const directorySids = dirs.map(directoryWriteSid)
+ for (const dir of dirs) {
+ const granted = readDirectAces(api, dir).filter(ace => directorySids.includes(ace.sid))
+ expect(granted.map(ace => ace.sid)).toEqual([directoryWriteSid(dir)])
+ }
+ } finally {
+ sandboxA.dispose()
+ sandboxB.dispose()
+ }
})
it('dispose revokes the revocable temp ACE and label and keeps the standing workspace pair (self-managed flow)', async () => {
@@ -388,14 +409,13 @@ describe.skipIf(!isWin32)('ACL editing', () => {
const sandbox = new AclSandbox({
writableDirs: [workspaceDir],
tempDir,
- writeSid: 'S-1-4-9000-3',
tempWriteSid: 'S-1-4-9000-3-1',
mode: 'workspace-write',
})
await sandbox.init()
sandbox.dispose()
const workspaceAces = readDirectAces(api, workspaceDir)
- expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true)
+ expect(workspaceAces.some(ace => ace.sid === directoryWriteSid(workspaceDir))).toBe(true)
expect(readLabelAces(api, workspaceDir)).toEqual([
{ sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP },
])
@@ -407,13 +427,12 @@ describe.skipIf(!isWin32)('ACL editing', () => {
it('rejects an overlapping private temp directory before applying either capability', async () => {
const workspaceDir = scratch()
const nestedTemp = join(workspaceDir, 'temp')
- const writeSid = 'S-1-4-9000-30'
+ const writeSid = directoryWriteSid(workspaceDir)
const privateTempSid = 'S-1-4-9000-30-1'
mkdirSync(nestedTemp)
const sandbox = new AclSandbox({
writableDirs: [workspaceDir],
tempDir: nestedTemp,
- writeSid,
tempWriteSid: privateTempSid,
mode: 'workspace-write',
})
@@ -424,19 +443,16 @@ describe.skipIf(!isWin32)('ACL editing', () => {
expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false)
})
- it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => {
+ it('workspace-write requires explicit temp authority and a nonempty token capability list', () => {
const dir = scratch()
- expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }))
- .toThrow(/requires a write SID/)
- expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
+ expect(() => new AclSandbox({ writableDirs: [dir], mode: 'workspace-write' }))
.toThrow(/requires an explicit private temp directory or null/)
- expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
+ expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, mode: 'workspace-write' }))
.toThrow(/requires a temp write SID/)
expect(() => new AclSandbox({
writableDirs: [dir],
tempDir: dir,
- writeSid: 'S-1-4-1-1',
- tempWriteSid: 'S-1-4-1-1',
+ tempWriteSid: directoryWriteSid(dir),
mode: 'workspace-write',
})).toThrow(/must be distinct/)
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write'))
@@ -497,7 +513,7 @@ describe.skipIf(!isWin32)('ACL editing', () => {
it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => {
const api = await win32()
const dir = scratch()
- const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
try {
await sandbox.init()
const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5')
diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts
index 64aac56080..2b39040490 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts
@@ -8,7 +8,7 @@
* acl.spec.ts and runner.spec.ts (win32 only).
*/
-import { mkdtempSync, rmSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Win32Error } from '@deepseek-ai/dsh-win32-process'
@@ -18,7 +18,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import koffi from 'koffi'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
-import { AclSandbox } from '../src/index.ts'
+import { AclSandbox, directoryWriteSid } from '../src/index.ts'
import * as abi from '../src/win32-abi.ts'
const PVOID = koffi.pointer('void')
@@ -29,6 +29,7 @@ type MockFn = ReturnType
interface HappyStubs {
api: Win32Bindings
setNamedSecurityInfoW: MockFn
+ setEntriesInAclW: MockFn
convertStringSidToSidW: MockFn
closeHandle: MockFn
localFree: MockFn
@@ -178,7 +179,7 @@ function happyStubs(): HappyStubs {
localFree, closeHandle, getLastError, formatMessageW,
})
return {
- api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree,
+ api, setNamedSecurityInfoW, setEntriesInAclW, convertStringSidToSidW, closeHandle, localFree,
createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce,
}
}
@@ -197,18 +198,27 @@ describe('AclSandbox constructor validation', () => {
it('resolves relative writable directories to absolute paths', () => {
const dir = scratch()
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' })
- expect(sandbox.writableDirs).toEqual([resolve(dir)])
+ expect(sandbox.writableDirs).toEqual([realpathSync.native(dir)])
expect(sandbox.mode).toBe('read-only')
expect(sandbox.tempDir).toBeUndefined()
})
+ it('derives one SID per canonical directory and keeps read-only free of directory capabilities', () => {
+ const workspace = scratch()
+ const memory = scratch()
+ const dirs = [workspace, memory, workspace]
+ const sandbox = new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'workspace-write' })
+ const canonical = [workspace, memory].map(path => realpathSync.native(path))
+ expect(sandbox.writableDirs).toEqual(canonical)
+ expect(sandbox.writeSids).toEqual(canonical.map(directoryWriteSid))
+ expect(new AclSandbox({ writableDirs: dirs, tempDir: null, mode: 'read-only' }).writeSids).toEqual([])
+ })
+
it('rejects temp authority under read-only', () => {
const workspace = scratch()
const temp = scratch()
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' }))
.toThrow(/read-only does not accept a temp directory/u)
- expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' }))
- .toThrow(/read-only does not accept write SIDs/u)
expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' }))
.toThrow(/read-only does not accept write SIDs/u)
})
@@ -218,7 +228,6 @@ describe('AclSandbox constructor validation', () => {
expect(() => new AclSandbox({
writableDirs: [workspace],
tempDir: null,
- writeSid: 'S-1-4-9000-2',
tempWriteSid: 'S-1-4-9000-2-1',
mode: 'workspace-write',
})).toThrow(/temp write SID requires a temp directory/u)
@@ -233,7 +242,6 @@ describe('AclSandbox init', () => {
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: temp,
- writeSid: 'S-1-4-9000-1',
tempWriteSid: 'S-1-4-9000-1-1',
mode: 'workspace-write',
})
@@ -242,16 +250,48 @@ describe('AclSandbox init', () => {
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2)
})
+ it('places every directory SID and the separate temp SID in the restricted token', async () => {
+ const { convertStringSidToSidW, createRestrictedToken, setEntriesInAclW } = state.stubs as HappyStubs
+ const workspace = scratch()
+ const memory = scratch()
+ const temp = scratch()
+ const parsed: bigint[] = []
+ convertStringSidToSidW.mockImplementation((_sid: string, slot: NativePtr) => {
+ const pointer = BigInt(101 + parsed.length)
+ parsed.push(pointer)
+ koffi.encode(slot, PVOID, pointer)
+ return 1
+ })
+ const sandbox = new AclSandbox({
+ writableDirs: [workspace, memory], tempDir: temp, tempWriteSid: 'S-1-4-9000-1-1', mode: 'workspace-write',
+ })
+ try {
+ await sandbox.init()
+ const expectedSids = [...sandbox.writeSids, sandbox.tempWriteSid]
+ expect(convertStringSidToSidW).toHaveBeenCalledTimes(expectedSids.length)
+ expectedSids.forEach((sid, index) => {
+ expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything())
+ })
+ const call = createRestrictedToken.mock.calls[0]!
+ expect(call[6]).toBe(5)
+ const entries = call[7] as Buffer
+ expect(parsed.map((_pointer, index) => entries.readBigUInt64LE((index + 2) * abi.SID_AND_ATTRIBUTES_SIZE))).toEqual(parsed)
+ const defaultDaclMerge = setEntriesInAclW.mock.calls.find(call => call[2] === 88n)!
+ const defaultEntry = defaultDaclMerge[1] as Buffer
+ expect(defaultEntry.readBigUInt64LE(abi.TRUSTEE_W_OFFSET + abi.TRUSTEE_W_PTSTRNAME_OFFSET)).toBe(parsed[2])
+ } finally { sandbox.dispose() }
+ })
+
it('requires an explicit private temp directory or null under workspace-write', () => {
const workspace = scratch()
- expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }))
+ expect(() => new AclSandbox({ writableDirs: [workspace], mode: 'workspace-write' }))
.toThrow(/requires an explicit private temp directory or null/u)
})
it('applies no grants when the temp dir option is null', async () => {
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await sandbox.init()
expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only
})
@@ -261,7 +301,6 @@ describe('AclSandbox init', () => {
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: join(scratch(), 'missing'),
- writeSid: 'S-1-4-9000-4',
tempWriteSid: 'S-1-4-9000-4-1',
mode: 'workspace-write',
})
@@ -269,11 +308,14 @@ describe('AclSandbox init', () => {
})
it('builds a read-only token without parsing a write SID or applying grants', async () => {
- const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
+ const { convertStringSidToSidW, setNamedSecurityInfoW, createRestrictedToken } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' })
+ const memory = scratch()
+ const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'read-only' })
await sandbox.init()
expect(convertStringSidToSidW).not.toHaveBeenCalled()
+ expect(createRestrictedToken.mock.calls[0]![6]).toBe(2)
+ expect(sandbox.writeSids).toEqual([])
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free
})
@@ -281,7 +323,7 @@ describe('AclSandbox init', () => {
it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => {
const { setNamedSecurityInfoW } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write', manageDacls: false })
await sandbox.init()
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke
@@ -289,7 +331,7 @@ describe('AclSandbox init', () => {
it('refuses a second init on the same instance', async () => {
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await sandbox.init()
await expect(sandbox.init()).rejects.toThrow(/already initialized/u)
})
@@ -298,23 +340,44 @@ describe('AclSandbox init', () => {
const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs
convertStringSidToSidW.mockReturnValue(0)
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
})
+ it('frees earlier directory SIDs when parsing a later directory SID fails', async () => {
+ const { convertStringSidToSidW, localFree, setNamedSecurityInfoW, closeHandle } = state.stubs as HappyStubs
+ const workspace = scratch()
+ const memory = scratch()
+ const firstSid = 101n as NativePtr
+ convertStringSidToSidW.mockImplementationOnce((_sid: string, slot: NativePtr) => {
+ koffi.encode(slot, PVOID, firstSid)
+ return 1
+ }).mockReturnValueOnce(0)
+ const sandbox = new AclSandbox({ writableDirs: [workspace, memory], tempDir: null, mode: 'workspace-write' })
+ await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' })
+ expect(convertStringSidToSidW).toHaveBeenCalledTimes(sandbox.writeSids.length)
+ sandbox.writeSids.forEach((sid, index) => {
+ expect(convertStringSidToSidW).toHaveBeenNthCalledWith(index + 1, sid, expect.anything())
+ })
+ expect(localFree).toHaveBeenCalledWith(firstSid)
+ expect(setNamedSecurityInfoW).not.toHaveBeenCalled()
+ expect(closeHandle).toHaveBeenCalledWith(2n)
+ expect(sandbox.tempDir).toBeUndefined()
+ })
+
it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => {
const { convertStringSidToSidW } = state.stubs as HappyStubs
convertStringSidToSidW.mockImplementation(() => 1) // no out slot write
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error)
})
it('aggregates failed current and restricted token closes after init', async () => {
const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
const restrictedToken = 99n
createRestrictedToken.mockImplementation((
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
@@ -361,7 +424,6 @@ describe('AclSandbox init', () => {
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: temp,
- writeSid: 'S-1-4-9000-10',
tempWriteSid: 'S-1-4-9000-10-1',
mode: 'workspace-write',
})
@@ -391,13 +453,13 @@ describe('AclSandbox spawn', () => {
it('refuses to spawn before init', () => {
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u)
})
it('pipe spawn drains empty pipes and settles with the child exit code', async () => {
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await sandbox.init()
const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace })
expect(child.pid).toBe(1234)
@@ -410,7 +472,7 @@ describe('AclSandbox spawn', () => {
it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => {
const { closeHandle } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await sandbox.init()
const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' })
await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 })
@@ -420,7 +482,7 @@ describe('AclSandbox spawn', () => {
it('inherit spawn reports a failed close of the kill-on-close job', async () => {
const { closeHandle, createJobObjectW } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await sandbox.init()
let jobHandle = 0n
closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1))
@@ -433,7 +495,7 @@ describe('AclSandbox spawn', () => {
describe('AclSandbox dispose', () => {
it('is a no-op before init', () => {
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
expect(() => { sandbox.dispose() }).not.toThrow()
})
@@ -444,7 +506,6 @@ describe('AclSandbox dispose', () => {
const sandbox = new AclSandbox({
writableDirs: [workspace],
tempDir: temp,
- writeSid: 'S-1-4-9000-16',
tempWriteSid: 'S-1-4-9000-16-1',
mode: 'workspace-write',
})
@@ -456,7 +517,7 @@ describe('AclSandbox dispose', () => {
it('aggregates SID and token cleanup failures into an AggregateError', async () => {
const { localFree } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
await sandbox.init()
localFree.mockReturnValue(1n)
expect(() => { sandbox.dispose() }).toThrow(AggregateError)
@@ -465,7 +526,7 @@ describe('AclSandbox dispose', () => {
it('reports a failed close of the restricted token', async () => {
const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs
const workspace = scratch()
- const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' })
+ const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'workspace-write' })
let restrictedToken = 0n
createRestrictedToken.mockImplementation((
_existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown,
diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts
index 117052da1c..dfa56d1520 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts
@@ -52,7 +52,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
sandbox = new AclSandbox({
writableDirs: [writableDir],
tempDir: isolatedTemp,
- writeSid: 'S-1-4-9000-4',
tempWriteSid: 'S-1-4-9000-4-1',
mode: 'workspace-write',
})
@@ -92,15 +91,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
}, 30_000)
- it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => {
+ it('fails closed when the temp SID cannot be parsed (no unrestricted fallback)', async () => {
// A malformed SID makes ConvertStringSidToSidW fail; init must throw
// before any grant is applied and never spawn unrestricted.
- const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
+ const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' })
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
}, 15_000)
it('failed init clears provisional temp state before a retry', async () => {
- const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
+ const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, tempWriteSid: 'S-1-4-abc-1', mode: 'workspace-write' })
const provisionalState = broken as unknown as { tempDirResolved: string | undefined }
provisionalState.tempDirResolved = isolatedTemp
diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts
index 413afe2f69..b639cac114 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts
@@ -6,14 +6,14 @@
*/
import { spawnSync } from 'node:child_process'
-import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
-import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts'
+import { AclWriteGrant, tempWriteSid, directoryWriteSid } from '../src/index.ts'
const isWin32 = process.platform === 'win32'
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
@@ -48,10 +48,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
let publicProbeDir: string | undefined
beforeAll(() => {
- scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-'))
+ scratchRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')))
writableDir = join(scratchRoot, 'writable')
mkdirSync(writableDir)
- isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-'))
+ isolatedTemp = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')))
secretFile = join(scratchRoot, 'secret.txt')
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
escapeFile = join(scratchRoot, 'escaped.txt')
@@ -135,21 +135,23 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(readFileSync(extraFile, 'utf8')).toBe('retained')
}, 90_000)
- it('accepts a provider-materialized SID covering the workspace and additional directories', () => {
+ it('accepts one provider-materialized SID per writable directory', () => {
const extra = mkdtempSync(join(scratchRoot, 'managed-extra-'))
const privateTemp = mkdtempSync(join(isolatedTemp, 'managed-set-'))
- const writeSid = workspaceWriteSid(writableDir, [extra])
+ const writeSid = directoryWriteSid(writableDir)
+ const extraSid = directoryWriteSid(extra)
const privateTempSid = tempWriteSid(privateTemp)
const directoryGrant = AclWriteGrant.create(writeSid)
+ const extraGrant = AclWriteGrant.create(extraSid)
const tempGrant = AclWriteGrant.create(privateTempSid)
try {
directoryGrant.add(writableDir, true)
- directoryGrant.add(extra, true)
+ extraGrant.add(extra, true)
tempGrant.add(privateTemp)
const target = join(extra, 'managed-write.txt')
const result = runRunner([
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write',
- '--writable-root', extra, '--write-sid', writeSid, '--temp-write-sid', privateTempSid,
+ '--writable-root', extra, '--write-sid', writeSid, '--write-sid', extraSid, '--temp-write-sid', privateTempSid,
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'managed')", target,
])
expect(result.error).toBeUndefined()
@@ -158,6 +160,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(readFileSync(target, 'utf8')).toBe('managed')
} finally {
directoryGrant.dispose()
+ extraGrant.dispose()
tempGrant.dispose()
rmSync(privateTemp, { recursive: true, force: true })
}
@@ -222,7 +225,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => {
const seamWorkspace = join(scratchRoot, 'seam-workspace')
mkdirSync(seamWorkspace)
- const writeSid = workspaceWriteSid(seamWorkspace)
+ const writeSid = directoryWriteSid(seamWorkspace)
const privateTemp = join(isolatedTemp, 'private-subdir')
mkdirSync(privateTemp)
const privateTempSid = tempWriteSid(privateTemp)
@@ -258,7 +261,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
}, 30_000)
it('temp capabilities isolate sibling sessions that share one workspace SID', () => {
- const writeSid = workspaceWriteSid(writableDir)
+ const writeSid = directoryWriteSid(writableDir)
const tempA = join(isolatedTemp, 'session-a')
const tempB = join(isolatedTemp, 'session-b')
mkdirSync(tempA)
@@ -389,7 +392,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
// it, so the workspace write is denied instead of leaking through the
// standing ACE. The switch back reuses the SAME standing ACE: the
// re-upgrade write lands without any re-grant.
- const writeSid = workspaceWriteSid(writableDir)
+ const writeSid = directoryWriteSid(writableDir)
const privateTemp = join(isolatedTemp, 'mode-switch-temp')
mkdirSync(privateTemp)
const privateTempSid = tempWriteSid(privateTemp)
@@ -506,7 +509,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
const otherTemp = join(scratchRoot, 'other-temp')
mkdirSync(otherWorkspace)
mkdirSync(otherTemp)
- const otherWorkspaceSid = workspaceWriteSid(otherWorkspace)
+ const otherWorkspaceSid = directoryWriteSid(otherWorkspace)
const otherTempSid = tempWriteSid(otherTemp)
const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid)
const otherTempGrant = AclWriteGrant.create(otherTempSid)
@@ -517,7 +520,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
const ownTemp = join(scratchRoot, 'own-temp')
mkdirSync(ownWorkspace)
mkdirSync(ownTemp)
- const ownWorkspaceSid = workspaceWriteSid(ownWorkspace)
+ const ownWorkspaceSid = directoryWriteSid(ownWorkspace)
const ownTempSid = tempWriteSid(ownTemp)
const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid)
const ownTempGrant = AclWriteGrant.create(ownTempSid)
@@ -594,7 +597,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
mkdirSync(child)
writeFileSync(join(granted, 'file.txt'), 'x')
writeFileSync(join(child, 'deep.txt'), 'x')
- const grant = AclWriteGrant.create(workspaceWriteSid(granted))
+ const grant = AclWriteGrant.create(directoryWriteSid(granted))
grant.add(granted, true)
try {
const probe = `
@@ -629,7 +632,7 @@ TryOpen 'DIRECTORY' '${child}'
// not strip the label the second one's child still writes through.
const shared = join(scratchRoot, 'shared-root')
mkdirSync(shared)
- const sidA = workspaceWriteSid(shared)
+ const sidA = directoryWriteSid(shared)
const sidB = `${sidA}-1`
const grantA = AclWriteGrant.create(sidA)
const grantB = AclWriteGrant.create(sidB)
@@ -711,11 +714,13 @@ TryOpen 'DIRECTORY' '${child}'
}, 90_000)
it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => {
- const writeSid = workspaceWriteSid(writableDir)
+ const writeSid = directoryWriteSid(writableDir)
const tempSid = tempWriteSid(isolatedTemp)
const cases = [
['--write-sid', writeSid],
['--writable-root', worldWritableDir, '--write-sid', writeSid, '--temp-write-sid', tempSid],
+ ['--writable-root', worldWritableDir, '--write-sid', directoryWriteSid(worldWritableDir), '--write-sid', writeSid, '--temp-write-sid', tempSid],
+ ['--write-sid', writeSid, '--write-sid', directoryWriteSid(worldWritableDir), '--temp-write-sid', tempSid],
['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid],
['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'],
]
diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts
index 82b9d055e0..bbb88ca38c 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts
@@ -1,48 +1,24 @@
-/**
- * workspaceWriteSid tests: the per-workspace write identity is deterministic
- * (the same canonical path always derives the same SID — the property the
- * cross-session grant reuse rests on), capability-shaped, distinct across
- * workspaces, and byte-sensitive (the canonical path is the caller's
- * contract; an alias spelling derives a second identity, self-healing at
- * the cost of one extra tree propagation).
- */
+/** Stable, distinct write identities for canonical directories and private temp paths. */
import { describe, expect, it } from 'vitest'
-import { tempWriteSid, workspaceWriteSid } from '../src/index.ts'
+import { directoryWriteSid, tempWriteSid } from '../src/index.ts'
-describe('workspaceWriteSid', () => {
- it('derives a stable capability-shaped SID per workspace path', () => {
- const first = workspaceWriteSid('C:\\Users\\agent\\repo')
- const second = workspaceWriteSid('C:\\Users\\agent\\repo')
+describe('directoryWriteSid', () => {
+ it('derives a stable capability-shaped SID per directory path', () => {
+ const first = directoryWriteSid('C:\\Users\\agent\\repo')
+ const second = directoryWriteSid('C:\\Users\\agent\\repo')
expect(first).toBe(second)
expect(first).toMatch(/^S-1-4-\d+-\d+$/u)
})
- it('uses the same identity for reordered and duplicated writable-directory sets', () => {
- const workspace = 'C:\\repo'
- const memory = 'C:\\memory'
- const cache = 'C:\\cache'
- const sid = workspaceWriteSid(workspace, [memory, cache])
- expect(workspaceWriteSid(workspace, [cache, workspace, memory, cache])).toBe(sid)
- expect(workspaceWriteSid(memory, [workspace, cache])).toBe(sid)
- expect(workspaceWriteSid(workspace, [workspace])).toBe(workspaceWriteSid(workspace))
- expect(sid).not.toBe(workspaceWriteSid(workspace))
- expect(sid).not.toBe(workspaceWriteSid(workspace, [memory]))
- })
-
- it('keeps path boundaries distinct when their concatenated text matches', () => {
- expect(workspaceWriteSid('C:\\a', ['C:\\bC:\\c']))
- .not.toBe(workspaceWriteSid('C:\\aC:\\b', ['C:\\c']))
- })
-
- it('derives distinct identities for distinct workspaces', () => {
- expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b'))
+ it('derives distinct identities for distinct directories', () => {
+ expect(directoryWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(directoryWriteSid('C:\\Users\\agent\\repo-b'))
})
it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => {
- expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo'))
- expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo'))
+ expect(directoryWriteSid('C:\\Repo')).not.toBe(directoryWriteSid('c:\\repo'))
+ expect(directoryWriteSid('C:\\Repo\\')).not.toBe(directoryWriteSid('C:\\Repo'))
})
})
@@ -51,7 +27,7 @@ describe('tempWriteSid', () => {
const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')
expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u)
- expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
+ expect(temp).not.toBe(directoryWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
})
it('derives distinct capabilities for distinct private temp paths', () => {
diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts
index 01d14f51ef..2ee587956b 100644
--- a/packages/sandbox/sandbox/src/index.ts
+++ b/packages/sandbox/sandbox/src/index.ts
@@ -53,7 +53,7 @@ export interface SandboxExecutionPolicy {
* Opaque identity of the calling session (the branded `dsh-session`
* SessionId). Backends key per-session state off it (e.g. windows-acl gives
* each live session/workspace pair a random private temp directory and SID,
- * while standing grants are keyed by the complete writable-directory set); absent
+ * while standing grants are keyed by individual writable directories); absent
* for agentless calls, which fall back to per-call backend state.
*/
sessionId?: SessionId
From 28484a44e15dd1115a75c8e5aeb4b12bd69314e4 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 28 Oct 2026 19:45:01 +0800
Subject: [PATCH 027/106] refactor(sandbox): remove obsolete unsupported-policy
error
---
packages/sandbox/sandbox-local/src/index.ts | 2 +-
packages/sandbox/sandbox/src/index.ts | 8 --------
packages/sandbox/sandbox/tests/vocabulary.spec.ts | 12 +-----------
3 files changed, 2 insertions(+), 20 deletions(-)
diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts
index c24e175548..18acb7cbd4 100644
--- a/packages/sandbox/sandbox-local/src/index.ts
+++ b/packages/sandbox/sandbox-local/src/index.ts
@@ -238,7 +238,7 @@ const RUNNER_FAILURE_RULES = {
/**
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
* chain verdict and, on the windows-acl rung, the write grants
- * ({@link AclWriteGrant}: standing grants per writable-directory set
+ * ({@link AclWriteGrant}: standing grants per writable directory
* and the revocable private-temp grant per live session/workspace pair, the
* latter revoked on provider dispose); the one-time probes spawn nothing
* else.
diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts
index 2ee587956b..2d3eb81a5d 100644
--- a/packages/sandbox/sandbox/src/index.ts
+++ b/packages/sandbox/sandbox/src/index.ts
@@ -151,14 +151,6 @@ export class SandboxUnavailableError extends HarnessError {
}
}
-/** The selected backend is available but cannot enforce the requested policy. */
-export class SandboxUnsupportedPolicyError extends HarnessError {
- constructor(detail: string) {
- super(detail, 'SANDBOX_UNSUPPORTED_POLICY')
- this.name = 'SandboxUnsupportedPolicyError'
- }
-}
-
declare module '@deepseek-ai/cordis' {
interface Context {
sandbox: SandboxProvider
diff --git a/packages/sandbox/sandbox/tests/vocabulary.spec.ts b/packages/sandbox/sandbox/tests/vocabulary.spec.ts
index 000168e726..caba42b2a7 100644
--- a/packages/sandbox/sandbox/tests/vocabulary.spec.ts
+++ b/packages/sandbox/sandbox/tests/vocabulary.spec.ts
@@ -7,17 +7,7 @@
*/
import { describe, expect, it } from 'vitest'
-import { SANDBOX_UNAVAILABLE, SandboxUnavailableError, SandboxUnsupportedPolicyError } from '@deepseek-ai/dsh-sandbox'
-
-describe('SandboxUnsupportedPolicyError', () => {
- it('distinguishes an unsupported policy from an unavailable backend', () => {
- const error = new SandboxUnsupportedPolicyError('The selected backend cannot grant additional directories.')
- expect(error).toBeInstanceOf(Error)
- expect(error.name).toBe('SandboxUnsupportedPolicyError')
- expect(error.code).toBe('SANDBOX_UNSUPPORTED_POLICY')
- expect(error.message).toBe('The selected backend cannot grant additional directories.')
- })
-})
+import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
describe('SandboxUnavailableError', () => {
it('carries the structured { name, code } identity consumers key on', () => {
From 2028ef3d26127bf2974a01d6cb416998ba730dee Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Wed, 28 Oct 2026 19:48:42 +0800
Subject: [PATCH 028/106] test(sandbox): inspect the derived directory SID in
ACL assertions
---
packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts
index 255f15c5dc..d1fa520dd3 100644
--- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts
+++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts
@@ -516,7 +516,7 @@ describe.skipIf(!isWin32)('ACL editing', () => {
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })
try {
await sandbox.init()
- const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5')
+ const grant = readDirectAces(api, dir).find(ace => ace.sid === sandbox.writeSids[0])
expect(grant).toBeDefined()
const mask = grant?.mask ?? 0
expect(mask).toBe(abi.GRANT_MASK)
From d6e89120d180804f552c5b80f0320d6cee77df08 Mon Sep 17 00:00:00 2001
From: Yixiang Tu
Date: Thu, 29 Oct 2026 11:05:40 +0800
Subject: [PATCH 029/106] fix(sandbox): compare root paths with host semantics
Reuse derived directory SIDs during initialization and align documentation with per-directory grants.
---
...ndows-directory-write-identities.i18n.yaml | 8 ++---
...0-28-windows-directory-write-identities.md | 4 ++-
...8-windows-directory-write-identities.zh.md | 4 ++-
...ows-acl-restricted-token-sandbox.i18n.yaml | 20 ++++++------
...08-windows-acl-restricted-token-sandbox.md | 10 +++---
...windows-acl-restricted-token-sandbox.zh.md | 10 +++---
.../windows-directory-sids/guide.i18n.yaml | 8 ++---
.../windows-directory-sids/guide.md | 7 ++--
.../windows-directory-sids/guide.zh.md | 7 ++--
.../sandbox-windows-acl/README.i18n.yaml | 26 +++++++--------
.../sandbox/sandbox-windows-acl/README.md | 32 +++++++++----------
.../sandbox/sandbox-windows-acl/README.zh.md | 30 ++++++++---------
.../sandbox/sandbox-windows-acl/src/grant.ts | 6 ++--
.../sandbox/sandbox-windows-acl/src/index.ts | 28 ++++++++--------
.../sandbox/sandbox-windows-acl/src/runner.ts | 2 +-
.../sandbox/sandbox-windows-acl/src/token.ts | 5 +--
16 files changed, 108 insertions(+), 99 deletions(-)
diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml
index 583e918ef5..1dcb2d8646 100644
--- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.i18n.yaml
@@ -9,11 +9,11 @@
en: ce436e11d96c3c96
zh: dcda6f85413a5be6
/agent-note-windows-per-directory-write-identities/decision:
- en: edbc0f1001721065
- zh: a91b9b2c1e19e6d2
+ en: 2b778019f042b4c7
+ zh: 6477f4b8a4d05cab
/agent-note-windows-per-directory-write-identities/alternatives-considered:
en: d87e2cf9196d66e7
zh: 04261fa61efe407b
/agent-note-windows-per-directory-write-identities/consequences:
- en: b9c9e40cf1e09f4e
- zh: 6e69f792bbdd7726
+ en: f20026b54351fcb4
+ zh: 350ff60897c1f4bd
diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
index 7076514a10..47bd25f0a2 100644
--- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
+++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.md
@@ -14,6 +14,8 @@ Each canonical writable directory has one deterministic SID, granted only to tha
`AclSandbox` derives identities from canonical, deduplicated paths instead of accepting a caller-selected SID for an arbitrary list. Every parsed SID has an allocation owner, including partial initialization failures. Private temp capabilities remain separate per live Session/workspace pair; the token default DACL still prefers the private temp SID. Running processes retain their launch tokens when later executions change their permitted roots.
+Directory grants compare supplied and physical paths with Node’s host-specific `path.relative`, while SID derivation retains the physical spelling. This accepts ordinary Windows casing differences without adding a component-walking implementation. The comparison assumes case-insensitive Windows paths; legacy 8.3 aliases remain unsupported. This deliberately favors the common path syntax over exhaustive filesystem-identity handling.
+
## Alternatives considered
**One SID per complete directory set.** Sorted, deduplicated JSON encoding gives an unambiguous identity and separates differently authorized executions. It also adds another ACE to every unchanged directory for each new combination and repeats tree propagation. Per-directory identities provide the same future-execution semantics with independent reuse.
@@ -22,4 +24,4 @@ Each canonical writable directory has one deterministic SID, granted only to tha
## Consequences
-Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The underlying restricted-token and hard-link limitations remain unchanged.
+Existing workspace-only SID values remain stable. Standing directory ACL edits outlive executions, while token membership controls new execution authority. Native Windows regressions cover multiple directory capabilities, omitted-directory denial, separate temp grants, and one explicit SID grant per directory; portable tests cover token contents, allocation cleanup, cache reuse, and failed grant retries. The [Windows ACL decision](../feature/2026-08-08-windows-acl-restricted-token-sandbox.md) retains the restricted-token mechanism and hard-link limitations; this decision replaces its single-workspace identity model.
diff --git a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md
index ecaab18b84..d078334d53 100644
--- a/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-28-windows-directory-write-identities.zh.md
@@ -14,6 +14,8 @@ Status: implemented
`AclSandbox` 从规范化、去重后的路径派生身份,不接受调用方为任意目录列表选择一个 SID。每个解析后的 SID 都有明确的分配所有者,包括部分初始化失败时。私有临时能力仍按活跃 Session/工作区对独立;令牌默认 DACL 继续优先使用私有临时 SID。后续执行改变允许根目录时,已运行进程保留启动令牌。
+目录授权使用 Node 按主机平台实现的 `path.relative` 比较传入路径与物理路径,SID 派生则保留物理路径的拼写。这接受普通 Windows 大小写差异,无需加入逐级遍历路径的实现。比较假定 Windows 路径不区分大小写;旧式 8.3 别名仍不受支持。这里有意优先支持常见路径语法,而不穷尽文件系统身份处理。
+
## 考虑过的替代方案
**每个完整目录集合一个 SID。** 排序、去重后的 JSON 编码可生成无歧义身份,并隔离不同权限的执行。但每种新组合都会向未变化的目录添加另一个 ACE,并重复目录树传播。逐目录身份通过独立复用提供相同的后续执行语义。
@@ -22,4 +24,4 @@ Status: implemented
## 影响
-已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。底层受限令牌及硬链接限制保持不变。
+已有的仅工作区 SID 值保持稳定。常驻目录 ACL 编辑比执行存活更久,新执行的权限由令牌携带的 SID 控制。原生 Windows 回归覆盖多个目录能力、未包含目录的拒绝、独立临时授权,以及每个目录仅显式授予自身 SID;可移植测试覆盖令牌内容、分配清理、缓存复用与授权失败重试。[Windows ACL 决策](../feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)保留受限令牌机制与硬链接限制;本决策替换其单工作区身份模型。
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
index 8217e734a6..ed74eb3498 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
@@ -9,14 +9,14 @@
en: f62d87b7623a9904
zh: 48bbb7c35298f96c
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
- en: 0cd47aef022d86ad
- zh: 1f8c5006db51c97b
+ en: 90fd9a7cf5e25d03
+ zh: 916b1eac07da4eb6
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
- en: 1ba89a61a733b371
- zh: 4fa134ad1b2059a1
+ en: 3bc4bd8f6d4588c8
+ zh: e4760533aa152a5d
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/alternatives-considered:
en: 6119e2aff3b6be5a
zh: 4237c596c10e1ee9
@@ -30,14 +30,14 @@
en: e2c573d90c4ddeb9
zh: f0259213e1321722
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/consequences:
- en: 5b9657f4247b6a79
- zh: e024d34bbab082a7
+ en: 883c393d20d42da3
+ zh: 541fdf737114b7ae
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/testing:
- en: b223cb2cf13193c9
- zh: 61ddd0a0b99f8f56
+ en: 2175317fcb25a52d
+ zh: 076cee14f261f85e
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/related:
- en: dd16f0c5815df991
- zh: df3c1cf511ee52bf
+ en: da294868c980f138
+ zh: 8700a7146483b0b8
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/follow-up-2026-10-26-lua-token-is-dropped:
en: 5262178f0035b215
zh: 10505c394ea33ee3
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
index 412933dbf1..78297a2d41 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
@@ -10,7 +10,7 @@ The original [historical sandbox decision](../../archived/feature/2026-07-06-san
## Decision
-Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
+Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`; `LUA_TOKEN` is not used) whose restricting SIDs carry distinct directory and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). Each writable directory has a deterministic SID derived from its canonical path (`directoryWriteSid` — sha256 → `S-1-4-x-y`); its standing ACE is reusable across permission combinations, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire a shared directory capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A writable root equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, ...directory SIDs, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing directory ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (otherwise the first directory SID, or Everyone when neither is present) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
## ACL diagnosis and recovery
@@ -22,7 +22,7 @@ Both extracted and recovery scripts are user-writable, so this workflow never re
## How the restriction works (why no new identity)
-The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
+The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the permitted-directory and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE` disables every privilege except `SeChangeNotifyPrivilege`; `LUA_TOKEN` is not used, so the derived token keeps the caller's group SIDs. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
## Alternatives considered
@@ -40,14 +40,16 @@ The landstrip evaluation was rejected before implementation (not battle-tested;
## Consequences
-Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
+Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the standing capabilities for their permitted directories but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../../../../packages/subprocess/subprocess-local/README.md) preserves console inheritance); standing directory security-descriptor mutations (the reuse cache, plus old ACEs when a directory is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree directory propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per directory per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits.
## Testing
-The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence.
+The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, per-directory and temp argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins directory/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, directory/temp SID validation, sibling temp denial under shared directory SIDs, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence.
## Related
+The [per-directory identity decision](../architecture/2026-10-28-windows-directory-write-identities.md) replaces the single workspace capability with independently reusable directory capabilities; this note retains the restricted-token mechanism and its limitations.
+
The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes.
The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here.
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
index daea838039..38303084b9 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## 决策
-直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
+直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的目录能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。每个可写目录都有从其规范路径确定性派生的 SID(`directoryWriteSid`——sha256 → `S-1-4-x-y`);其常驻 ACE 可在不同权限组合之间复用,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的目录能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果可写根目录等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、全部目录 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻目录 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(否则继承第一个目录 SID,两者均不存在时继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
## ACL 诊断与恢复
@@ -23,7 +23,7 @@ Status: implemented
## How the restriction works (why no new identity)
-身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
+身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有获准目录与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
## 考虑过的替代方案
@@ -41,14 +41,16 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
## 后果
-所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
+所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享其获准目录的常驻能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../../../../packages/subprocess/subprocess-local/README.zh.md)保留控制台继承);目录常驻安全描述符改动(复用缓存,以及目录改名后的旧 ACE)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;目录授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个目录只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。
## 测试
-产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。
+产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、逐目录与临时目录 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住目录/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、目录/临时目录 SID 验证、共享目录 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。
## Related
+[逐目录身份决策](../architecture/2026-10-28-windows-directory-write-identities.zh.md)将单一工作区能力替换为可独立复用的目录能力;本笔记保留受限令牌机制及其限制。
+
[pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。
[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。
diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml
index 15f12a8dbc..71ce04f47f 100644
--- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml
+++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.i18n.yaml
@@ -9,8 +9,8 @@
en: fd8ac37f696dac64
zh: b5531e3c276b0c88
/windows-acl-directory-identities/change:
- en: ae9055a597ae4e32
- zh: 9fe43036ec0372ac
+ en: 2893ccdcc064e754
+ zh: 5ced21fcad53bbaf
/windows-acl-directory-identities/migration:
- en: 601f6832ab87b74f
- zh: 568afc8d964c4da7
+ en: f3512bba3f54f294
+ zh: 98f2bc1de08c0c8b
diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
index fb0ab47ad2..79c3264396 100644
--- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
+++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.md
@@ -9,11 +9,12 @@ English | [中文](guide.zh.md)
## Change
-`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. Users of the standard `dsh` profiles need no configuration changes.
+`@deepseek-ai/dsh-sandbox-windows-acl` replaces `workspaceWriteSid` with `directoryWriteSid`. `AclSandbox` derives a separate identity for each canonical writable directory: its constructor no longer accepts `writeSid`, and its `writeSid` property is replaced by the readonly `writeSids` array. The private temp SID remains separate. `writableDirs` is now readonly and contains canonical, deduplicated paths. Windows accepts valid additional writable directories, and `@deepseek-ai/dsh-sandbox` removes `SandboxUnsupportedPolicyError` and its `SANDBOX_UNSUPPORTED_POLICY` code. Users of the standard `dsh` profiles need no configuration changes.
## Migration
-1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities.
+1. In direct `AclSandbox` callers, remove the `writeSid` option. Continue passing `writableDirs`, `mode`, and the explicit `tempDir`; retain `tempWriteSid` when temp writes are enabled. Read `writeSids` when inspecting the derived identities. Do not mutate `writableDirs`; construct a new instance for a different directory list. Compare its entries with canonical paths rather than the original input spellings.
2. Replace `workspaceWriteSid(path)` imports with `directoryWriteSid(path)` when materializing grants outside `AclSandbox`. Give each canonical directory only its own SID; do not grant one SID across several directories.
3. For caller-managed runner invocations, repeat `--write-sid` once per canonical directory, matching the deduplicated workspace-then-extra-root order, and retain `--temp-write-sid`. Agentless invocations omit both kinds of SID flag.
-4. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions.
+4. Remove imports and catches for `SandboxUnsupportedPolicyError` and checks for `SANDBOX_UNSUPPORTED_POLICY`; Windows supports valid extra directories instead of rejecting that policy.
+5. Compile the caller and check on Windows that a new process can write its permitted directories and cannot write a directory omitted from its policy. Existing processes keep their launch permissions.
diff --git a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md
index c9bd0b60c4..134debe76b 100644
--- a/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md
+++ b/docs/upgrade-guide/v0.2.1-rc.4/windows-directory-sids/guide.zh.md
@@ -9,11 +9,12 @@ description: "Windows ACL 调用方使用派生的逐目录 SID,不再提供
## 变更
-`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。标准 `dsh` profile 的用户无需修改配置。
+`@deepseek-ai/dsh-sandbox-windows-acl` 用 `directoryWriteSid` 替换 `workspaceWriteSid`。`AclSandbox` 为每个规范可写目录派生独立身份:构造器不再接受 `writeSid`,其 `writeSid` 属性替换为只读 `writeSids` 数组。私有临时 SID 继续独立。`writableDirs` 现为只读,并包含规范化、去重后的路径。Windows 接受有效的额外可写目录,`@deepseek-ai/dsh-sandbox` 移除 `SandboxUnsupportedPolicyError` 及其 `SANDBOX_UNSUPPORTED_POLICY` 错误码。标准 `dsh` profile 的用户无需修改配置。
## 迁移
-1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。
+1. 在直接调用 `AclSandbox` 的代码中移除 `writeSid` 选项。继续传入 `writableDirs`、`mode` 和显式 `tempDir`;启用临时目录写入时保留 `tempWriteSid`。检查派生身份时读取 `writeSids`。不要修改 `writableDirs`;目录列表变化时创建新实例。将其条目与规范路径比较,而非原始输入拼写。
2. 在 `AclSandbox` 之外物化授权时,将 `workspaceWriteSid(path)` 导入替换为 `directoryWriteSid(path)`。每个规范目录只授予其自身的 SID,不要将同一 SID 授予多个目录。
3. 对于调用方管理授权的 runner 调用,按去重后先工作区、再额外根目录的顺序,为每个规范目录重复一次 `--write-sid`,并保留 `--temp-write-sid`。无 agent 调用省略这两类 SID 标志。
-4. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。
+4. 移除 `SandboxUnsupportedPolicyError` 的导入与捕获,以及对 `SANDBOX_UNSUPPORTED_POLICY` 的检查;Windows 支持有效的额外目录,不再拒绝该策略。
+5. 编译调用方,并在 Windows 上验证新进程可以写入允许的目录,且无法写入策略中未包含的目录。已有进程保留启动时的权限。
diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
index aa6a067db2..5ae1a26af5 100644
--- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
+++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 510d0b1ef7db159d
zh: dc2cf25b3db14c63
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/direct-api:
- en: 16596607bbccc2a3
- zh: 30bc370a5281bf0d
+ en: 0208dc12f0be12ea
+ zh: 3ce871ac35bd1828
/deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you:
en: 0ae33482d28f637b
zh: a2b87b18737a6d07
@@ -33,17 +33,17 @@
en: c2cde86c85c2c619
zh: a29a114e591f9d3f
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/mechanism:
- en: 3981364f27a7ce01
- zh: 1ac67fa0481a63d4
+ en: 4ffc57b0582af92c
+ zh: 8c6fe31d52065f2b
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/modes-and-token-lists:
- en: 5a0d49485508f4a8
+ en: 60094d4cff20b7cc
zh: f332d2d2ba2d8170
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/the-confinement-runner:
- en: 8515514d01b74c47
- zh: f0c24d2b3bd3abfd
+ en: 7daba96f71c4b02b
+ zh: a92e6ae40dc118a7
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/verified-boundaries:
- en: ad02570eae90037f
- zh: fd78c2318de18dda
+ en: 0f87fd641ace67fb
+ zh: c9fa22ca5225f264
/deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation/header-verification-and-source-map:
en: c19df6b7a756b779
zh: b8267b26bd191d97
@@ -57,11 +57,11 @@
en: 817eb1b7de13872e
zh: 06fe175e41e7459b
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work:
- en: 046831a864786b2a
- zh: 4a261fbab3396ea4
+ en: 4cc129728a9005b7
+ zh: 89fac806fb43b41b
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note:
en: cc273320f6a1beaf
zh: 5c57a312ddc50651
/deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note/future-warnings-and-cleanup-surfaces:
- en: 8872c7b4c8ce1cc7
- zh: c7fb4eefa013c7b0
+ en: ea48de0150a65991
+ zh: 2b98931436064513
diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md
index ce151e9269..c953838294 100644
--- a/packages/sandbox/sandbox-windows-acl/README.md
+++ b/packages/sandbox/sandbox-windows-acl/README.md
@@ -45,8 +45,8 @@ const workspaceRoot = process.cwd()
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
// mode selects the token's restricting-SID list (see Modes below) and must
-// match the grant shape. workspace-write requires distinct workspace and
-// private-temp identities; pass tempDir: null to disable temp writes.
+// match the grants. workspace-write derives one SID per writable directory
+// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes.
const sandbox = new AclSandbox({
writableDirs: [workspaceRoot],
tempDir,
@@ -58,11 +58,11 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
const { stdout, stderr, exitCode } = await child.wait()
-sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair
+sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants
rmSync(tempDir, { recursive: true, force: true })
```
-The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs.
+The writable-directory security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs.
### What confinement gives you
@@ -94,11 +94,11 @@ This section explains the restricted-token mechanism, the token lists, the runne
### Mechanism
-The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
+The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry a capability for each writable directory and a separate private-temp capability, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. `directoryWriteSid(directory)` derives one stable SID per canonical path, preserving existing workspace identities. `AclSandbox` canonicalizes and deduplicates its writable directories, derives their SIDs, and grants each SID only on its matching directory. A new process carries all currently allowed directory SIDs; running processes retain their launch token. Permission changes reuse unchanged directories' standing ACEs through the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share authority for their permitted directories without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction.
### Modes and token lists
-`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
+`workspace-write` (logon SID, Everyone, directory SIDs, temp SID) grants the selected writable directories and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: standing directory grants from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation.
NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working.
@@ -112,23 +112,23 @@ The seam-facing shape is the runner entry (`./runner`): an argv-prefix wrapper `
node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid ] --
```
-The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
+The seam passes extra roots as repeated `--writable-root` arguments and reuses a grant entry per canonical directory, rechecking its ACE before execution so recreated directories receive their grant, then creates a random private temp directory and a distinct revocable SID for each live session/workspace pair, passing one `--write-sid` per directory together with `--temp-write-sid`; the runner verifies the directory SIDs in deduplicated workspace-then-extra-root order and the temp SID against its private path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter. Without these SID flags, `--temp` names a root: an agentless workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. Re-granting a standing directory ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips the re-propagation when the exact ACE already stands. A writable root equal to or containing the temp root is rejected before any grant; private temp directories must be disjoint from every writable root.
When launched with the subprocess control marker, the runner forwards fd 7 through the restricted child's CRT startup table and closes its own copy immediately after spawn. The optional `controlFileDescriptor: 7` input requires `stdio: 'inherit'`; requesting it with piped stdio fails before process creation.
### Verified boundaries
- **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed.
-- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly.
+- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working where the directory DACL grants them DELETE directly.
- **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite.
- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed.
-- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations.
+- **Hard links are file-object aliases, not path aliases** — an inheritable directory grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations.
- **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected.
-- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module.
-- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision.
+- **Security descriptor edits are standing directory mutations** — directory ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module.
+- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — each granted directory's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside that directory, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision.
- **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement.
- **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root.
-- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories.
+- **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing directory SIDs cannot write one another's temp directories.
- **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure.
### Header verification and source map
@@ -178,12 +178,12 @@ One extra catalog entry on Windows: the skill's description enters context with
These limits define when the backend is a poor fit or needs special operational care. They are current package constraints, not a general Windows comparison or a task backlog.
-- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root — the seam does exactly this, keyed by the workspace path.
+- **Directory grants remain cached for the provider lifetime** — the provider retains an `AclWriteGrant` and its native SID allocations for every canonical directory it has granted, until provider disposal. New executions select the permitted directory SIDs; changing that selection does not release cached grants or alter running tokens.
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual removal reclaims it.
-- **Standing workspace ACEs are invisible residue** — renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only), and a future cleanup command may reap them.
+- **Standing edits also affect extra directories** — the directory capability ACE, Low label, and Everyone `FILE_DELETE_CHILD` deny persist after plugin removal and provider disposal, including on user directories outside the workspace such as `~/.dsh/memory`. Renaming a directory derives a new SID but leaves the old ACE behind; the standing Low label and deny retain the effects described above.
- **NULL-DACL directories are not identity-preserving under grant+revoke** — a directory with a NULL DACL means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an empty (deny-all) DACL rather than the original NULL DACL. Real workspace and temp directories carry real DACLs, so this stays a documented edge.
- **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE.
-- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap.
+- **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-directory identity pays it once per directory per machine, and the exact-ACE skip makes every later provision cheap.
- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement.
- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access.
- **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue.
@@ -200,6 +200,6 @@ This Dev Note is working context for maintainers: undecided directions and open
#### Future: warnings and cleanup surfaces
-The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing workspace ACEs from renamed workspaces is undecided. Both are open directions, not shipped behavior.
+The warn-only posture for unusually wide directories and FAT-class volumes is documented in the limitations above but not implemented, and a cleanup command that reaps standing directory ACEs from renamed directories is undecided. Both are open directions, not shipped behavior.
diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md
index af080d999b..f47ff9aef4 100644
--- a/packages/sandbox/sandbox-windows-acl/README.zh.md
+++ b/packages/sandbox/sandbox-windows-acl/README.zh.md
@@ -45,8 +45,8 @@ const workspaceRoot = process.cwd()
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
// mode selects the token's restricting-SID list (see Modes below) and must
-// match the grant shape. workspace-write requires distinct workspace and
-// private-temp identities; pass tempDir: null to disable temp writes.
+// match the grants. workspace-write derives one SID per writable directory
+// and requires a distinct private-temp SID; pass tempDir: null to disable temp writes.
const sandbox = new AclSandbox({
writableDirs: [workspaceRoot],
tempDir,
@@ -58,11 +58,11 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
const { stdout, stderr, exitCode } = await child.wait()
-sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair
+sandbox.dispose() // revokes the revocable (temp) grant and label, keeps standing directory grants
rmSync(tempDir, { recursive: true, force: true })
```
-工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。
+可写目录的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。
### 隔离给你带来什么
@@ -96,7 +96,7 @@ rmSync(tempDir, { recursive: true, force: true })
### 机制
-调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
+调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 为每个可写目录携带一项能力,并另带独立的私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。`directoryWriteSid(directory)` 为每个规范路径派生一个稳定 SID,保留已有工作区身份。`AclSandbox` 将可写目录规范化并去重,派生各自的 SID,且仅在对应目录上授予该 SID。新进程携带当前允许的全部目录 SID,运行中的进程保留启动令牌。权限变化通过精确 ACE/精确拒绝/精确标签跳过,复用未变目录的常驻 ACE。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享其获准目录的权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。
### 模式与令牌列表
@@ -114,23 +114,23 @@ Authenticated Users 在两种列表中都不存在——WMI 命名空间安全
node runner.js --workspace --temp --mode [--writable-root ]... [--write-sid ]... [--temp-write-sid