Merge remote-tracking branch 'origin/master' into fix-webplugins-watch-flake

# Conflicts:
#	packages/host/webserver/src/web-plugins.ts
#	packages/host/webserver/tests/web-plugins.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-25 01:58:59 +08:00
28 changed files with 294 additions and 107 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-23-client-plugin-loading-model.md: 5a26561be300eefc4bbbadae5d3cc26ba4068f47
2026-07-23-client-plugin-loading-model.zh.md: baa524e33b45afd290f8be2c6ae57f10fa4968b7
2026-07-23-client-plugin-loading-model.md: c8869f068bf6d715345d56145907568c8310499e
2026-07-23-client-plugin-loading-model.zh.md: 1ceaba1b36e32a5db7c0a51e6d9f113eb9dd8339

View File

@@ -76,7 +76,7 @@ Why is the roster a hand-written list and not a scan? Because which plugins comp
Whether hot reload is active is a composition decision: dev graphs include the `client-hmr` row (a normal plugin package) and turn on bundle watching; prod graphs do neither.
How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode one registry-owned interval stat-polls every scanned bundle file against the stat baseline its own scan captured (synchronously, immediately before hashing that content — not `fs.watchFile`, whose asynchronous first-stat baseline silently absorbs a write landing during registry construction). Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change the registry re-hashes that row (`rebuilt(id)`); when the `rev` actually changed, it broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. The poll iterates the live table, so rescans retarget the watch for free (fresh rows carry fresh baselines) and dispose clears the one timer. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev.
How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode one registry-owned interval stat-polls every scanned bundle file against an explicit baseline the registry captures synchronously before construction returns (not `fs.watchFile`, whose asynchronous first-stat baseline silently absorbs a rebuild landing during registry construction — a CI-reproduced miss). Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change the registry re-hashes that row (`rebuilt(id)`); when the `rev` actually changed, it broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Rescans stage table, graph, and watch baselines atomically (a failed rescan keeps all three previous values), and a bundle missing at poll time marks its watch dirty so the reappearing file re-hashes even with identical metadata; dispose clears the one timer. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev.
On the browser side, the driver reloads one plugin per frame, serialized:

View File

@@ -76,7 +76,7 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
热重载是否启用是一项组合决策dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视prod 图两者皆无。
重建好的 bundle 怎么变成重载信号webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下由注册表自持的单个定时器对每个已扫描的 bundle 文件做 stat 轮询,比对基线是扫描自己捕获的 stat同步地、恰在哈希该内容之前采集——不用 `fs.watchFile`:它以异步首次 stat 建立基线,会把注册表构造期间落盘的写入静默吸收进基线。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`);当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSEServer-Sent Events通道连接即发全量图变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。轮询直接遍历活表,因此重扫天然重定向监视(新行自带新基线),dispose资源释放只需清掉那一个定时器。轮询间隔是一个经校验的配置字段默认 500ms不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
重建好的 bundle 怎么变成重载信号webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下由注册表自持的单个定时器对每个已扫描的 bundle 文件做 stat 轮询,比对基线由注册表在构造返回之前同步捕获(不用 `fs.watchFile`:它以异步首次 stat 建立基线,会把注册表构造期间落盘的重建静默吸收进基线——CI 上复现过的漏报。轮询是刻意选择inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`);当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSEServer-Sent Events通道连接即发全量图变更时发 `rebuilt` 帧,仅供呈现的 wire永不进会话日志。重扫对表、图、监视基线三者原子换入(重扫失败则三者都保持旧值);轮询时 bundle 缺失会给该监视打上 dirty 标记,文件重现时即使元数据相同也强制重哈希;dispose资源释放只需清掉那一个定时器。轮询间隔是一个经校验的配置字段默认 500ms不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
浏览器侧,驱动插件每帧重载一个插件,串行执行:

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-serial-cross-platform-ci-reference.md: b795a0aff62c20967d2c85429c0c6115c1b9585d
2026-07-21-serial-cross-platform-ci-reference.zh.md: 223fd9cf20a1d8228cb0c6b1b2f3f95644becae6
2026-07-21-serial-cross-platform-ci-reference.md: 3c0ae200d7dbd5b04eae6db2d6628dccc72103bf
2026-07-21-serial-cross-platform-ci-reference.zh.md: 5c159e12739d68e0baed72aaa08331072e2c3601

View File

@@ -18,6 +18,10 @@ Reviewers also need a direct answer to a simpler question: what happens when the
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The three operating-system jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
Platform ownership remains explicit inside that complete aggregate. `pty-local` supports Linux and macOS and therefore owns its unit and per-file coverage contract on POSIX rather than loading a backend that rejects `win32`; the Windows run still executes every portable package. Portable fixtures derive native paths through `node:path`, compare canonical identities with the same native realpath implementation as production, and use filenames legal on every host. ACP snapshot runs also pass both JavaScript and native realpath spellings of their generated cwd to the normalizer, which replaces aliases longest-first so Windows short and long paths cannot churn shared fixtures.
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
@@ -36,4 +40,6 @@ The workflow contains duplicated setup steps and a master reference run can take
The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate.
The explicit `pty-local` ownership boundary means Windows does not claim coverage for a backend it cannot load, and forked macOS unit workers cost more process startup time. In return, every supported surface has an honest platform oracle, a native runtime abort cannot erase the rest of the unit result, and timing-sensitive observers start from state established before callers can mutate it.
Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane.

View File

@@ -18,6 +18,10 @@ Status: implemented
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci``DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁覆盖率、快照回放、built-bin 冒烟测试和发布验证的并发数也设为 1。三种操作系统的作业可以彼此并行但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrapWindows 则在安装采用符号链接的工作区前启用开发人员模式。
该完整聚合流程仍明确划分平台归属。`pty-local` 支持 Linux 与 macOS因此其单元测试和逐文件覆盖率契约由 POSIX 平台负责,而不会在 Windows 上加载一个明确拒绝 `win32` 的后端Windows 仍会执行所有可移植包package。可移植 fixture测试前置数据通过 `node:path` 派生原生路径,使用与生产代码相同的原生 realpath 实现比较规范化后的路径标识并采用所有宿主机均允许的文件名。ACPAgent Client Protocol快照运行还会把生成的 cwd 分别通过 realpath 的 JavaScript 实现与原生实现得到的两种表示一并传给规范化器;规范化器按长度从长到短替换这些别名,避免 Windows 的短路径与长路径表示差异导致共享 fixture 反复变化。
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复开发构建产物的轮询逻辑每次发布重新扫描结果前都会先暂存候选表、候选图和候选监视基线映射构建产物缺失后会一直保持脏状态直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest``macos-latest``windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
@@ -36,4 +40,6 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
参考流程可能暴露某些平台上的故障而优化后的阻塞门禁集合尚未声明支持这些平台Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。
明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。
移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。

View File

@@ -1,4 +1,5 @@
import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -48,7 +49,7 @@ function seedWorkspace(
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
async function seedResumeSession(cwd: string): Promise<void> {
const sessionCwd = await realpath(cwd)
const sessionCwd = realpathSync.native(cwd)
const id = SessionId('resume-target')
const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd }
const events: SessionEvent[] = [

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
@@ -60,7 +61,7 @@ describe('dsh-tui-demo app', () => {
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
expect(calls[4]?.config).toEqual({ path: '/tmp/tui-sessions/session-query.db' })
expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') })
expect(calls[5]?.config).toEqual({
maxReferences: 2,
candidateLimit: 7,

View File

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { join, resolve, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
@@ -729,13 +729,13 @@ describe('sandbox escalation surface (write/edit)', () => {
it('a plain write stamps the default mode with the calling session root', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }])
})
it('a standing session override folds onto the stamp', async () => {
const { ctx, fs } = await setupConfining()
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }])
})
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
@@ -768,7 +768,7 @@ describe('sandbox escalation surface (write/edit)', () => {
agent: escalationAgent() as never,
signal: new AbortController().signal,
})
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: '/session-project' }])
expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }])
})
it('a rejected escalation fails closed with its own text and never mutates', async () => {

View File

@@ -8,6 +8,8 @@ Client-disconnect detection hangs off the **response** `close` event, not the re
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and reported to `onError`; it never becomes a process-killing unhandled rejection.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
## Model Experience
None, as the package is a pure HTTP carrier between the browser and the injected API handler; nothing here reaches a model request.

View File

@@ -22,7 +22,7 @@
*/
import { createHash } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
import { readFileSync, statSync, type Stats } from 'node:fs'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
@@ -105,13 +105,10 @@ export interface WebPluginRegistryDeps {
/** Sink for rescan failures (the initial scan throws instead — misconfiguration fails loud at load). */
onError: (err: Error) => void
/**
* Dev-mode bundle watching: one registry-owned interval stat-polls every
* scanned row's client bundle (polling by design: network mounts deliver no
* inotify events) and re-hashes + notifies onRebuilt subscribers on change.
* Each row's stat baseline is captured synchronously before its content is
* hashed, so a rebuild landing while the registry constructs is still
* detected on the first tick (fs.watchFile's asynchronous baseline lost
* that window). Absent = no watching (prod composition).
* Dev-mode bundle watching: stat-poll every scanned row's client bundle
* with an explicit stat baseline (polling by design: network mounts deliver
* no inotify events) and re-hash + notify onRebuilt subscribers on change.
* Absent = no watching (prod composition).
*/
watch?: {
/** Stat-poll interval in milliseconds; default 500 (the build-side watcher's polling default). */
@@ -130,15 +127,13 @@ interface DshClientDeclaration {
interface WebPluginRecord {
entry: WebBootEntry
clientPath: string
/**
* Bundle stat captured immediately BEFORE the content read that produced
* `entry.rev` — the watch baseline. The stat→read order makes a write
* racing the scan converge instead of being absorbed: landing between stat
* and read leaves the hash newer than the baseline (next tick re-hashes to
* the same rev, no spurious notify); landing after the read leaves the
* baseline older (next tick detects, re-hashes, notifies).
*/
stat: { mtimeMs: number; size: number }
}
interface WatchedBundle {
path: string
mtimeMs: number
size: number
dirty: boolean
}
/** Narrow an unknown parsed JSON value to the dshClient declaration, throwing on malformed fields. */
@@ -215,59 +210,84 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
throw new Error(`web-plugins: watch.intervalMs must be a positive integer (got ${String(deps.watch?.intervalMs)})`)
}
const stageWatches = (
candidateTable: Map<string, WebPluginRecord>,
currentWatches: Map<string, WatchedBundle>,
): Map<string, WatchedBundle> => {
const candidateWatches = new Map<string, WatchedBundle>()
if (watchInterval === undefined) return candidateWatches
for (const [id, record] of candidateTable) {
const current = currentWatches.get(id)
if (current?.path === record.clientPath) {
candidateWatches.set(id, { ...current })
continue
}
const baseline = statSync(record.clientPath)
candidateWatches.set(id, {
path: record.clientPath,
mtimeMs: baseline.mtimeMs,
size: baseline.size,
dirty: false,
})
}
return candidateWatches
}
let table = scan(deps)
let graph = composeGraph(table)
let watched = stageWatches(table, new Map())
const rebuildListeners = new Set<(id: string, rev: string) => void>()
const rebuilt = (id: string): string | undefined => {
const record = table.get(id)
if (record === undefined) return undefined
// stat BEFORE read, like scan(): a write racing this pair converges (see
// WebPluginRecord.stat) instead of desynchronizing baseline and rev.
const stat = statSync(record.clientPath)
const rev = shortHash(readFileSync(record.clientPath))
record.stat = { mtimeMs: stat.mtimeMs, size: stat.size }
record.entry = graphRow(id, rev, record.entry.inject, record.entry.immediately === true)
graph = composeGraph(table)
return rev
}
// Dev bundle watch: one registry-owned setInterval stat-polls every table
// row against the record's own baseline. fs.watchFile is unusable here: it
// captures its comparison baseline with an ASYNCHRONOUS first stat, so a
// rebuild landing between scan()'s content read and that stat is absorbed
// into the baseline and never reported — and the missed window is exactly
// registry construction, when a dev build is most likely to be finishing.
// The record baseline has no such window: scan()/rebuilt() stat before they
// read, so any write the hash missed is newer than the baseline and lands
// on the next tick. A torn read of a half-written bundle self-heals the
// same way — the ongoing write keeps changing the stats.
const pollTick = (): void => {
for (const [id, record] of table) {
let stat: { mtimeMs: number; size: number }
// Dev bundle watch: capture every row's baseline synchronously before the
// registry is returned, then poll those baselines. fs.watchFile establishes
// its first baseline asynchronously, so an immediate rebuild can otherwise
// become the baseline and disappear without an observed delta.
const pollWatches = (): void => {
for (const [id, watch] of watched) {
let current: Stats
try {
stat = statSync(record.clientPath)
current = statSync(watch.path)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') continue // mid-rename window; the completed write lands on a later tick
if (code === 'ENOENT') {
watch.dirty = true
continue
}
deps.onError(error instanceof Error ? error : new Error(String(error)))
continue
}
if (stat.mtimeMs === record.stat.mtimeMs && stat.size === record.stat.size) continue
const before = record.entry.rev
if (!watch.dirty && current.mtimeMs === watch.mtimeMs && current.size === watch.size) continue
const before = table.get(id)?.entry.rev
let rev: string | undefined
try {
rev = rebuilt(id)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') continue // vanished between stat and read; same self-heal
if (code === 'ENOENT') {
watch.dirty = true
continue
}
watch.mtimeMs = current.mtimeMs
watch.size = current.size
deps.onError(error instanceof Error ? error : new Error(String(error)))
continue
}
watch.mtimeMs = current.mtimeMs
watch.size = current.size
watch.dirty = false
if (rev === undefined || rev === before) continue
for (const notify of rebuildListeners) {
// A throwing subscriber must not skip later subscribers or escape
// into the timer callback (that would kill the process).
// A throwing subscriber must not skip later subscribers or escape the
// polling callback into the process event loop.
try {
notify(id, rev)
} catch (error) {
@@ -276,8 +296,8 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
}
}
}
const pollTimer = watchInterval === undefined ? undefined : setInterval(pollTick, watchInterval)
pollTimer?.unref()
const watchTimer = watchInterval === undefined ? undefined : setInterval(pollWatches, watchInterval)
watchTimer?.unref()
let pending = false
const unsubscribe = deps.ctx.on('internal/plugin', () => {
@@ -286,10 +306,12 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
queueMicrotask(() => {
pending = false
try {
// The poll iterates `table` directly, so the swap also retargets the
// watch: fresh records carry fresh stat baselines from scan().
table = scan(deps)
graph = composeGraph(table)
const candidateTable = scan(deps)
const candidateGraph = composeGraph(candidateTable)
const candidateWatches = stageWatches(candidateTable, watched)
table = candidateTable
graph = candidateGraph
watched = candidateWatches
} catch (error) {
// Keep serving the previous graph: a mid-flight rescan failure must not
// take down the boot manifest for plugins that were fine.
@@ -308,7 +330,8 @@ export function createHostWebPluginRegistry(deps: WebPluginRegistryDeps): HostWe
},
dispose: () => {
unsubscribe()
if (pollTimer !== undefined) clearInterval(pollTimer)
if (watchTimer !== undefined) clearInterval(watchTimer)
watched.clear()
rebuildListeners.clear()
},
}
@@ -330,13 +353,8 @@ function scan(deps: WebPluginRegistryDeps): Map<string, WebPluginRecord> {
throw new Error(`web-plugins: ${name} declares dshClient but exports no "./client" bundle`)
}
const clientPath = join(dirname(pkgPath), clientRel)
const stat = statSync(clientPath)
const rev = shortHash(readFileSync(clientPath))
table.set(name, {
entry: graphRow(name, rev, decl.inject, decl.immediately === true),
clientPath,
stat: { mtimeMs: stat.mtimeMs, size: stat.size },
})
table.set(name, { entry: graphRow(name, rev, decl.inject, decl.immediately === true), clientPath })
}
return table
}

View File

@@ -1,11 +1,41 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import {
mkdirSync,
mkdtempSync,
statSync,
type PathLike,
type Stats,
unlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createHostWebPluginRegistry, injectBootManifest } from '../src/index.ts'
import type { LoaderEntryView, WebPluginRegistryDeps } from '../src/index.ts'
const fsControl = vi.hoisted(() => ({ failNextStatPath: undefined as string | undefined }))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
statSync: (path: PathLike): Stats => {
if (String(path) === fsControl.failNextStatPath) {
fsControl.failNextStatPath = undefined
throw Object.assign(new Error('staged bundle missing'), { code: 'ENOENT' })
}
return actual.statSync(path)
},
}
})
afterEach(() => {
fsControl.failNextStatPath = undefined
vi.useRealTimers()
})
/** Write a fake installed package (package.json + optional client bundle) and return its package.json path. */
function makePkg(root: string, name: string, pkg: Record<string, unknown>, withBundle = true): string {
const dir = join(root, name.replaceAll('/', '__'))
@@ -151,8 +181,8 @@ describe('createHostWebPluginRegistry', () => {
// The old fs.watchFile watch captured its comparison baseline with an
// ASYNCHRONOUS first stat; a rewrite in the same tick as construction was
// absorbed into that baseline and never reported (the CI flake). The
// record-baseline poll stats synchronously before hashing, so this exact
// timing must now always notify.
// synchronous stageWatches baseline stats before the registry returns, so
// this exact timing must now always notify.
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
deps.watch = { intervalMs: 20 }
const registry = createHostWebPluginRegistry(deps)
@@ -168,6 +198,58 @@ describe('createHostWebPluginRegistry', () => {
registry.dispose()
})
it('watch mode: a failed rescan baseline preserves the published table and graph', async () => {
const { deps, entries, errors, ctx, root } = makeDeps([
{ name: 'stable', pkg: webDecl() },
{ name: 'late', pkg: webDecl(), loaded: false },
])
deps.watch = { intervalMs: 1_000 }
const registry = createHostWebPluginRegistry(deps)
const before = registry.graph()
;(entries[1] as { fiber?: unknown }).fiber = {}
fsControl.failNextStatPath = join(root, 'late', 'lib', 'client.js')
ctx.emit('internal/plugin', ctx.fiber)
await Promise.resolve()
expect(errors[0]?.message).toContain('staged bundle missing')
expect(registry.graph()).toBe(before)
expect(registry.clientPath('late')).toBeUndefined()
ctx.emit('internal/plugin', ctx.fiber)
await Promise.resolve()
expect(registry.graph().entries.map(row => row.id)).toEqual(['stable', 'late'])
registry.dispose()
})
it('watch mode: a missing bundle forces a re-hash when identical metadata reappears', async () => {
vi.useFakeTimers()
const { deps, root } = makeDeps([{ name: 'watched', pkg: webDecl() }])
const bundle = join(root, 'watched', 'lib', 'client.js')
const fixedTime = new Date(1_600_000_000_000)
utimesSync(bundle, fixedTime, fixedTime)
deps.watch = { intervalMs: 20 }
const registry = createHostWebPluginRegistry(deps)
const baseline = statSync(bundle)
const rebuilds: { id: string; rev: string }[] = []
registry.onRebuilt((id, rev) => rebuilds.push({ id, rev }))
unlinkSync(bundle)
await vi.advanceTimersByTimeAsync(20)
writeFileSync(bundle, 'x'.repeat(baseline.size))
utimesSync(bundle, fixedTime, fixedTime)
const restored = statSync(bundle)
expect({ mtimeMs: restored.mtimeMs, size: restored.size }).toEqual({
mtimeMs: baseline.mtimeMs,
size: baseline.size,
})
await vi.advanceTimersByTimeAsync(20)
expect(rebuilds).toHaveLength(1)
expect(registry.graph().entries[0]?.rev).toBe(rebuilds[0]?.rev)
registry.dispose()
})
it('rejects a non-positive or non-integer watch interval at build time', () => {
for (const intervalMs of [0, -5, 1.5]) {
const { deps } = makeDeps([{ name: 'p', pkg: webDecl() }])

View File

@@ -1,12 +1,12 @@
# @deepseek-ai/dsh-pty-local
Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platform fails as unsupported. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
## Plugin (`pty-local`)
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable or the ordinary silence bound expires. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.

View File

@@ -288,11 +288,12 @@ export class LocalPtySession implements PtyBackendSession {
if (sanitized.prompt) {
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
}
// Bash can print PROMPT_COMMAND before the kernel publishes its return
// to the foreground process group. Retain the marker; polling below is
// the authority that accepts it only after bash owns the foreground.
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
@@ -312,8 +313,11 @@ export class LocalPtySession implements PtyBackendSession {
return
}
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
this.settleActive('stdin_read')
return
const pgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid !== undefined && pgid === this.shellPgid) {
this.settleActive('stdin_read')
return
}
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
@@ -324,6 +328,10 @@ export class LocalPtySession implements PtyBackendSession {
return
}
}
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout.
if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return

View File

@@ -187,7 +187,12 @@ describe('LocalPtyBackend startup rollback', () => {
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
resize() {}, clear() {}, pause() {}, resume() {},
} as IPty
const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal)
const backend = new LocalPtyBackend(
ctx,
config(),
{ ...inspector, foregroundPgid: () => terminal.pid },
() => terminal,
)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
await session.close('test complete')

View File

@@ -286,7 +286,7 @@ describe('LocalPtySession readiness and output', () => {
expect(session.motd).toBe('dsh> ')
})
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
@@ -297,15 +297,30 @@ describe('LocalPtySession readiness and output', () => {
let settled = false
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07spoofed')
await vi.advanceTimersByTimeAsync(10)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(40)
expect(settled).toBe(false)
inspector.pgid = 456
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(true)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const operation = session.startSend({ text: 'bash -i', submit: true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07child> ')
await vi.advanceTimersByTimeAsync(100)
expect((await operation.done).waitReason).toBe('inferred_idle')
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {

View File

@@ -69,7 +69,7 @@ describe('SandboxPolicyService', () => {
})
})
it('resolves a symlink-sensitive session cwd with filesystem semantics', async () => {
it.skipIf(process.platform === 'win32')('resolves a symlink-sensitive session cwd with POSIX component semantics', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-policy-cwd-'))
try {
const lexical = join(root, 'lexical')
@@ -78,7 +78,7 @@ describe('SandboxPolicyService', () => {
mkdirSync(lexical)
mkdirSync(child, { recursive: true })
const link = join(lexical, 'link')
symlinkSync(child, link, process.platform === 'win32' ? 'junction' : 'dir')
symlinkSync(child, link, 'dir')
const cwd = `${link}${sep}..`
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/fallback' })

View File

@@ -14,7 +14,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
describe('canonicalPath', () => {
it('resolves symlinks (an existing path realpaths)', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
expect(canonicalPath(dir)).toBe(realpathSync(dir))
expect(canonicalPath(dir)).toBe(realpathSync.native(dir))
})
it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => {
@@ -30,9 +30,9 @@ describe('writableRoots', () => {
it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
expect(roots).toContain(realpathSync(ws))
expect(roots).toContain(realpathSync.native(ws))
expect(roots).toContain(canonicalPath('/tmp'))
expect(roots).toContain(realpathSync(tmpdir()))
expect(roots).toContain(realpathSync.native(tmpdir()))
// Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
expect(new Set(roots).size).toBe(roots.length)
})

View File

@@ -1,7 +1,7 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
@@ -20,6 +20,15 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const builtScripts = join(repoRoot, 'packages/sdk/scripts/lib/bin.js')
const temporary: string[] = []
function resolveCorepackHome(): string {
return process.env.COREPACK_HOME ?? join(
process.env.XDG_CACHE_HOME
?? process.env.LOCALAPPDATA
?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'),
'node/corepack',
)
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
@@ -71,13 +80,16 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
}
`)
const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name)
const pnpmStore = name === 'pnpm'
? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim()
: undefined
const commandEnvironment = {
...scrubEnvironment(),
COREPACK_HOME: join(cacheRoot, 'corepack'),
XDG_CACHE_HOME: join(cacheRoot, 'cache'),
COREPACK_HOME: resolveCorepackHome(),
...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') },
XDG_DATA_HOME: join(cacheRoot, 'data'),
npm_config_cache: join(cacheRoot, 'npm'),
pnpm_config_store_dir: join(cacheRoot, 'pnpm-store'),
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
}
await execFileAsync(name, manager.installCommand(), {
cwd: root,

View File

@@ -251,17 +251,15 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
})
it('surfaces non-ENOENT snapshot stat failures after discovery', async () => {
const blocker = join(root, 'snapshot-not-a-directory')
await writeFile(blocker, 'x')
const persistence = ctx.sessionPersistence as unknown as {
listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
}
const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
header: meta('snapshot-stat-failure'),
path: join(blocker, 'session.jsonl'),
path: `${root}\0snapshot-stat-failure`,
}])
await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/ENOTDIR/)
await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/)
discovery.mockRestore()
})

View File

@@ -6,7 +6,7 @@ Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:

View File

@@ -17,7 +17,7 @@
*/
import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { existsSync, realpathSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { tmpdir } from 'node:os'
import { basename, dirname, join, delimiter } from 'node:path'
@@ -141,6 +141,8 @@ export interface RunResult {
sessionId?: string
/** The generated cwd the session ran in (the bash workspace). */
cwd: string
/** Filesystem-resolved spellings of {@link cwd} that child processes may report. */
cwdAliases: string[]
/**
* Every persisted session log harvested after the run, ordered primary-first:
* the top-level (parent) session — the one with no `parentSession` — then each
@@ -222,6 +224,7 @@ export function snapshotSpillRoot(
*/
export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> {
const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-'))
const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])]
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn expected outputs.
@@ -329,6 +332,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
rawStdout: launched.rawStdout(),
stderr: launched.stderr(),
cwd,
cwdAliases,
...sessionId !== undefined ? { sessionId } : {},
sessionLogs,
}

View File

@@ -46,6 +46,8 @@ export interface NormalizeContext {
sessionIds: string[]
/** The generated cwd the run used — replaced with `{{cwd}}`. */
cwd: string
/** Other filesystem spellings of the same cwd (for example Windows short and long paths). */
cwdAliases?: readonly string[]
}
/** How cwd-rooted path separators are represented after the cwd is tokenized. */
@@ -60,9 +62,13 @@ export interface NormalizeOptions {
/** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string {
let out = value
// cwd first (longest, most specific), then explicit session ids, then any
// residual UUID (covers ids that appear in places we didn't enumerate).
out = out.split(ctx.cwd).join(CWD)
// Filesystem APIs can report one directory with several spellings. Replace
// every known spelling longest-first so a shorter alias cannot corrupt a
// longer one before it is tokenized.
const cwdSpellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])]
.filter(spelling => spelling.length > 0)
.sort((left, right) => right.length - left.length)
for (const spelling of cwdSpellings) out = out.split(spelling).join(CWD)
out = out.split(`/private${CWD}`).join(CWD)
if (cwdPathMode === 'canonical') {
// Restrict separator conversion to paths rooted at the cwd token. A global

View File

@@ -629,6 +629,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
...result.sessionLogs.map(l => l.id),
],
cwd: result.cwd,
cwdAliases: result.cwdAliases,
}
// Record writes live model fixtures; keyless refresh writes every comparable replayed

View File

@@ -44,6 +44,24 @@ describe('normalizeStdout', () => {
expect(out).not.toContain(ctx.sessionIds[0] as string)
})
it('scrubs every filesystem spelling of the cwd longest-first', () => {
const longCwd = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snapshot`
const aliasedCtx: NormalizeContext = {
sessionIds: [],
cwd: String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snapshot`,
cwdAliases: [
longCwd,
String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp`,
],
}
const raw = JSON.stringify({
cwd: longCwd,
path: `${longCwd}\\nested\\proof.txt`,
})
const frame = JSON.parse(normalizeStdout(raw, aliasedCtx)) as { cwd: string; path: string }
expect(frame).toEqual({ cwd: '{{cwd}}', path: '{{cwd}}/nested/proof.txt' })
})
it('canonicalizes only cwd-rooted path separators', () => {
const windowsCtx: NormalizeContext = {
sessionIds: [],

View File

@@ -1877,7 +1877,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await mkdir(join(cwd, 'docs'), { recursive: true })
await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
await writeFile(join(cwd, 'unsafe\u007ffile.ts'), 'unsafe name\n')
const result = await setup({
cwd,
tools: {

View File

@@ -29,8 +29,8 @@ describe('cordisConfigFiles', () => {
}
expect(cordisConfigFiles(root)).toEqual([
'examples/agent.cordis.yaml',
'examples/headless.cordis.yml',
join('examples', 'agent.cordis.yaml'),
join('examples', 'headless.cordis.yml'),
])
})
})

View File

@@ -11,6 +11,7 @@ const windowsUnsupportedPackages = process.platform === 'win32'
? [
'packages/bash/*',
'packages/hooks/*',
'packages/pty/pty-local',
'packages/sandbox/sandbox-local',
'packages/sdk/create-sdk',
'packages/sdk/helper',
@@ -59,7 +60,10 @@ export default defineConfig({
plugins: [pathsPlugin()],
test: {
name: 'thread-safe',
pool: 'threads',
// Node 24 has aborted in its CJS lexer from a macOS arm64 worker
// thread. A fork contains that external runtime failure to the test
// process; other hosts retain the lower-overhead thread pool.
pool: process.platform === 'darwin' ? 'forks' : 'threads',
setupFiles: ['./scripts/test-invariants.ts'],
include: testIncludes,
exclude: [