From 9820b6a1e9c3604555b3451d650cd58b64e9f3c6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 22:57:58 +0800 Subject: [PATCH 01/21] fix(cli): derive the shipped agent-preset root per composition The boot-time agent-presets overlay replaced the composed roots with the shipped root alone, so roots configured in a profile's cordis.patch.yml vanished from the roster (externally reported in deepseek-ai/deepseek-harness#3636). The overlay also froze the row's boot-time config above every live reload and never reached the config dump, which therefore showed roots the boot dropped. Derive the roster patch from the current layers instead: prepend the shipped root (system trust, wins duplicate ids) to configured roots, share one builder across boot, live user-layer reloads, and --dump-config, and fail loud on a roots value the launcher cannot statically rewrite. Fixes #2863. --- ...pped-preset-root-per-composition.i18n.yaml | 6 + ...ive-shipped-preset-root-per-composition.md | 31 +++++ ...-shipped-preset-root-per-composition.zh.md | 31 +++++ apps/cli/src/dump-config.ts | 14 ++- apps/cli/src/profile-boot.ts | 111 +++++++++++++----- apps/cli/tests/built-bin.e2e.ts | 13 ++ apps/cli/tests/shipped-preset-root.spec.ts | 89 ++++++++++++++ apps/cli/tests/web-agent-presets.e2e.ts | 107 ++++++++++++----- packages/bundle/web-app/cordis.patch.yml | 8 +- 9 files changed, 346 insertions(+), 64 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md create mode 100644 apps/cli/tests/shipped-preset-root.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml new file mode 100644 index 0000000000..f5c3964d7a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md +2026-08-20-derive-shipped-preset-root-per-composition.md: b303f6a5d08ac2c2ca755d5dbf46eb9a74f5c4ee +2026-08-20-derive-shipped-preset-root-per-composition.zh.md: cc28789898a74df285a96b7e17c35e3b4d12c452 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md new file mode 100644 index 0000000000..b303f6a5d0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md @@ -0,0 +1,31 @@ +# Agent Note: Derive the shipped preset root per composition + +Status: implemented + +English | [中文](2026-08-20-derive-shipped-preset-root-per-composition.zh.md) + +## Problem + +`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's own writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them — the include's own contract that a dump can never drift from what boots was broken by a patch the dump never saw. Externally reported with an accurate root cause in discussion #3636. + +The overlay also sat in `ComposedProfile.overlays`, the fixed top layers a live reload replays above fresh user layers. Overlays exist so a user edit cannot displace launcher facts, which is right for `--patch` files and the telemetry switch — but the roster patch had captured the whole boot-time `config`, so after boot no `cordis.patch.yml` edit to the row (`default`, `includeUserRoot`, `roots`) could take effect until restart. + +## Decision + +The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the one builder boot, the live user-layer reloads, and the config dump all go through — a reload derives from the current user layers instead of replaying a boot snapshot, and the dump now renders the derived layer (labeled `dsh launcher (shipped agent-preset root)`) so it composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. + +A `roots` value the launcher cannot statically rewrite — a `!!js` expression or any non-array — now fails loud with a `TypeError` naming the constraint, instead of being silently replaced. The plugin's own contract is untouched: `config.roots` scanned in order, the writable home root appended by `dsh-agent-presets` itself. + +## Testing + +`shipped-preset-root.spec.ts` covers the derivation directly: prepend order, key preservation, absence without a roster row, per-call derivation, the fail-loud rejections, and the squash regression through a full `composeEntries` application. The Web composition e2e now obtains the shipped root through the real `composeProfilePatches` instead of hand-writing the launcher's patch (three boots had replicated it literally, one admitting "exactly what `composeProfile` supplies"), and adds a configured-roots boot: a shared root's preset lists beside the shipped four, a directory claiming a shipped id is shadowed by it, and a configured-root preset composes an agent. The built-bin dump acceptance asserts the derived layer's label and the shipped-before-configured root order. No keyless snapshot changes: default compositions produce byte-identical stacks, and the snapshot harness has no custom-profile lane — the real-composition e2e is the assembled-application evidence here. + +## Alternatives considered + +**The reporter's fix: prepend inside the boot-time overlay.** Correct on the squash and the priority order, and kept as the shape of the derived patch. Rejected as-is because the overlay would still freeze the whole boot-time `config` above every later reload, leaving the row's live edits dead until restart. + +**Provide the shipped root out of band (a launcher-provided context value the plugin prepends).** Cleanest hot-reload story — no config rewriting at all — but it moves an assembly fact into the plugin's service contract, adds a launcher-coupled provide key to a package that otherwise only reads config, and makes the effective roots invisible to the config dump. The derived patch keeps the roster's inputs entirely in the composition. + +## Consequences + +Configured preset roots survive boot, live edits to the roster row take effect without restart, and the dump, the live tree, and the boot compose the row identically. The launcher constrains the roster row's `config`/`roots` to literal values; a composition that generated them with `!!js` would previously have had the expression silently discarded and now must materialize the array in a patch layer instead. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md new file mode 100644 index 0000000000..cc28789898 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Derive the shipped preset root per composition + +Status: implemented + +[English](2026-08-20-derive-shipped-preset-root-per-composition.md) | 中文 + +## 问题 + +`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁会整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 自己的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,所以 dump 显示配置的根目录完好而启动却丢弃了它们——include 自身"dump 永不偏离实际启动"的契约,被一个 dump 看不到的补丁打破。外部报告 discussion #3636 给出了准确的根因。 + +该 overlay 还位于 `ComposedProfile.overlays`——热重载在新鲜用户层之上重放的固定顶层。overlay 的存在意义是让用户编辑无法顶掉启动器事实,这对 `--patch` 文件和遥测开关是正确的——但 roster 补丁快照了启动时的整个 `config`,导致启动后对该行的任何 `cordis.patch.yml` 编辑(`default`、`includeUserRoot`、`roots`)在重启前都不生效。 + +## 决定 + +内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动、用户层热重载与配置 dump 共同经过的唯一构建器——热重载从当前用户层派生而非重放启动快照,dump 也渲染这个派生层(标注为 `dsh launcher (shipped agent-preset root)`),使 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 + +启动器无法静态改写的 `roots` 值——`!!js` 表达式或任何非数组——现在以指明约束的 `TypeError` 大声失败,而不是被静默替换。插件自身的契约不变:`config.roots` 按序扫描,可写 home 根由 `dsh-agent-presets` 自己追加。 + +## 测试 + +`shipped-preset-root.spec.ts` 直接覆盖派生逻辑:前置顺序、键保留、无 roster 行时不产出、逐次调用派生、大声失败的拒绝分支,以及经完整 `composeEntries` 应用验证的压掉回归。Web 组合 e2e 现在通过真实的 `composeProfilePatches` 获得内置根,不再手抄启动器补丁(此前三处启动逐字复制了它,其中一处自述"exactly what `composeProfile` supplies"),并新增配置根目录的启动场景:共享根的 preset 与内置四个并列出现、占用内置 id 的目录被其遮蔽、配置根中的 preset 能组合出 agent。built-bin dump 验收断言派生层标签及"内置根在配置根之前"的顺序。无 keyless 快照变更:默认组合产生的补丁栈逐字节相同,且快照框架没有自定义 profile 通道——真实组合 e2e 即是组装应用层面的证据。 + +## 曾考虑的替代方案 + +**报告者的修法:在启动时 overlay 内部做前置。** 对压掉问题与优先级顺序判断正确,派生补丁保留了这一形状。按原样采纳被否,因为该 overlay 仍会把启动时的整个 `config` 冻结在所有后续重载之上,该行的实时编辑在重启前依然失效。 + +**带外提供内置根(启动器提供的上下文值,由插件前置)。** 热重载故事最干净——完全不改写 config——但它把装配事实挪进插件的服务契约,给一个本只读 config 的包加上与启动器耦合的 provide 键,还让有效根目录对配置 dump 不可见。派生补丁把 roster 的输入完整留在组合之内。 + +## 后果 + +配置的 preset 根目录在启动后存活,对 roster 行的实时编辑无需重启即生效,dump、活动树与启动对该行的组合完全一致。启动器将 roster 行的 `config`/`roots` 约束为字面量;此前用 `!!js` 生成它们的组合本来就会被静默丢弃表达式,现在必须在某个补丁层实体化该数组。 diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 1754eb4efd..229a4c67ab 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -2,7 +2,8 @@ * Config-dump entry for `dsh --profile --dump-config`: compose the * profile's patch layers through the include plugin's patch algorithm without * booting or evaluating `!!js`, with one source layer per bundle, the - * profile's own patch file, and each `--patch` overlay. + * profile's own patch file, each `--patch` overlay, and the launcher-derived + * shipped agent-preset root. * @module @deepseek-ai/dsh/dump-config */ @@ -14,7 +15,7 @@ import { renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' +import { composeRows, homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME, resolveShippedPresetPatch } from './profile-boot.ts' const NAME = 'dsh' @@ -47,6 +48,15 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) }) } } + // The launcher derives one more layer no file carries: the shipped + // agent-preset root, prepended to whatever roots the layers configured. + // Included so the dump composes the roster row exactly as it boots. (The + // telemetry hard-disable switch stays out: it is an environment fact of the + // booting process, not part of the profile composition.) + const presetPatch = resolveShippedPresetPatch(composeRows(layers.map(layer => layer.patches))) + if (presetPatch !== undefined) { + layers.push({ label: `${NAME} launcher (shipped agent-preset root)`, patches: [presetPatch] }) + } // The dump anchors on the same empty root file the boot includes. process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)) } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 19c4abb245..bdb11462bd 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -16,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -120,12 +120,74 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return [ - ...composed.bundlePatches, - ...composed.profile.patches, - ...composed.homePatches, - ...composed.overlays, - ] + return composeProfilePatches([ + composed.bundlePatches, + composed.profile.patches, + composed.homePatches, + composed.overlays, + ]) +} + +/** + * Compose patch layers and index the resulting rows by id. + * @param layers - patch lists in application order. + * @returns id → composed row, for rows that carry a string id. + */ +export function composeRows(layers: readonly PatchOptions[][]): Map { + const rows = new Map() + for (const row of composeEntries(layers)) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows +} + +/** + * Derive the shipped agent-preset-root patch from one composed row set. The + * shipped root is the part of the roster only this app can resolve: it sits + * beside this app's own config, in both the source and built layouts. The + * derived patch keeps every configured key and PREPENDS the shipped root to + * the composition's `roots`, so the shipped presets always mount and win a + * duplicate id while configured roots stay live. (The writable root the + * roster appends is `dsh-agent-presets`' own, so a launcher that never + * reaches this patch still finds a person's presets.) + * @param rows - id → row of the composed tree the patch applies over. + * @returns the roster patch, or `undefined` when the composition has no roster row. + * @throws TypeError when the composed row's config or its `roots` is not a + * literal the launcher can rewrite (a `!!js` expression or a non-array value). + */ +export function resolveShippedPresetPatch(rows: ReadonlyMap): PatchOptions | undefined { + const row = rows.get('agent-presets') + if (row === undefined) return undefined + const config: unknown = row.config ?? {} + if (typeof config !== 'object' || config === null || Array.isArray(config) || isJsExpr(config)) { + throw new TypeError(`${NAME}: agent-presets config must be a literal mapping — the launcher prepends the shipped preset root into it`) + } + const configured = (config as Record).roots ?? [] + if (!Array.isArray(configured)) { + throw new TypeError(`${NAME}: agent-presets config.roots must be a literal array — the launcher prepends the shipped preset root into it`) + } + const configuredRoots: readonly unknown[] = configured + return { + id: 'agent-presets', + config: { + ...(config as Record), + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }, ...configuredRoots], + }, + } +} + +/** + * Compose one generation's full patch stack: the layers in application order, + * then the shipped preset-root patch derived from their composition. Shared + * by boot and the live user-layer reloads, so a reload derives the roster + * from the CURRENT user layers instead of replaying a boot-time snapshot — + * an edit to the row's config, `roots` included, keeps taking effect. + * @param layers - patch lists in application order. + * @returns the flattened stack with the derived roster patch appended. + */ +export function composeProfilePatches(layers: readonly PatchOptions[][]): PatchOptions[] { + const presetPatch = resolveShippedPresetPatch(composeRows(layers)) + return [...layers.flat(), ...presetPatch === undefined ? [] : [presetPatch]] } /** @@ -147,24 +209,11 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) - const rows = new Map() - for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { - if (typeof row.id === 'string') rows.set(row.id, row) - } + const rows = composeRows([bundlePatches, profile.patches, homePatches, overlays]) + // The shipped agent-preset root is NOT pushed here: it is derived from the + // current layers on every composition (`composeProfilePatches`), so a live + // user-layer edit to the roster row keeps taking effect. const composedOverlays = [...overlays] - // The SHIPPED root is the part of the roster only this app can resolve: it - // sits beside this app's own config, in both the source and built layouts. - // The writable root the roster appends is `dsh-agent-presets`' own, so a - // launcher that never reaches this patch still finds a person's presets. - if (rows.has('agent-presets')) { - composedOverlays.push({ - id: 'agent-presets', - config: { - ...(rows.get('agent-presets')?.config ?? {}) as Record, - roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], - }, - }) - } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } @@ -237,12 +286,14 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // objects in place. Reusing one parsed patch object across applications // would bake a user override into the bundle's in-memory insert row, so // removing the override could never revert the row to the bundle default. - const composeLive = (): PatchOptions[] => structuredClone([ - ...composed.bundlePatches, - ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], - ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlays, - ]) + // The derived shipped-preset patch is recomputed per generation from these + // fresh layers, never carried over from boot. + const composeLive = (): PatchOptions[] => structuredClone(composeProfilePatches([ + composed.bundlePatches, + loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + loadOptionalPatches(NAME, homePatchPath()) ?? [], + composed.overlays, + ])) // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 75ab640fcc..a2c4fa97c0 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -748,6 +748,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ' - id: personal', ' provider: personal-provider', ' model: personal-model', + '- id: agent-presets', + ' config:', + ' default: standard', + ' roots:', + ` - path: ${join(home, 'team-presets')}`, + ' trust: user', '- id: absent-row', ' config:', ' x: 1', @@ -772,6 +778,13 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toContain('personal-provider') // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) + // The dump composes the launcher-derived roster layer too: the shipped + // preset root is prepended to the user layer's roots, not replacing them. + expect(stdout).toContain('dsh launcher (shipped agent-preset root)') + const shippedRootAt = stdout.search(/config[\\/]+agent-presets/) + const configuredRootAt = stdout.search(/team-presets/) + expect(shippedRootAt).toBeGreaterThanOrEqual(0) + expect(configuredRootAt).toBeGreaterThan(shippedRootAt) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) }) diff --git a/apps/cli/tests/shipped-preset-root.spec.ts b/apps/cli/tests/shipped-preset-root.spec.ts new file mode 100644 index 0000000000..3f43daa96c --- /dev/null +++ b/apps/cli/tests/shipped-preset-root.spec.ts @@ -0,0 +1,89 @@ +import { sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { composeEntries } from '@deepseek-ai/dsh-app-boot' +import { composeProfilePatches, composeRows, resolveShippedPresetPatch } from '../src/profile-boot.ts' + +/** The web bundle's roster insert, reduced to the keys the derivation reads. */ +const bundleLayer: PatchOptions[] = [{ + insert: [{ id: 'agent-presets', name: '@deepseek-ai/dsh-agent-presets', config: { default: 'standard' } }], +}] + +const userLayer = (config: Record): PatchOptions[] => [{ id: 'agent-presets', config }] + +const shippedRoot = { path: expect.stringContaining(`config${sep}agent-presets`) as unknown, trust: 'system' } + +/** Apply a full patch stack the way boot does and return the roster row's mounted config. */ +function finalRosterConfig(patches: PatchOptions[]): Record { + const row = composeEntries([patches]).find(entry => entry.id === 'agent-presets') + if (row === undefined) throw new Error('missing agent-presets row') + return row.config as Record +} + +describe('resolveShippedPresetPatch', () => { + it('is absent for a composition without the roster row', () => { + const rows = composeRows([[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]]) + expect(resolveShippedPresetPatch(rows)).toBeUndefined() + }) + + it('prepends the shipped root to configured roots and preserves every other key', () => { + const rows = composeRows([bundleLayer, userLayer({ + default: 'minimal', + roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], + includeUserRoot: false, + })]) + expect(resolveShippedPresetPatch(rows)).toEqual({ + id: 'agent-presets', + config: { + default: 'minimal', + includeUserRoot: false, + roots: [shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }], + }, + }) + }) + + it('supplies the shipped root alone when the composition configures none', () => { + const patch = resolveShippedPresetPatch(composeRows([bundleLayer])) + expect(patch).toEqual({ id: 'agent-presets', config: { default: 'standard', roots: [shippedRoot] } }) + }) + + it('fails loud on a config it cannot statically rewrite', () => { + expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: 'nope' })]))) + .toThrow(TypeError) + expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: { __jsExpr: 'x' } })]))) + .toThrow(/literal array/) + expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, [{ id: 'agent-presets', config: { __jsExpr: 'x' } }]]))) + .toThrow(/literal mapping/) + }) +}) + +describe('composeProfilePatches', () => { + it('keeps configured roots effective through the whole patch application', () => { + // The squash this stack exists to prevent: the derived patch must extend + // the user layer's roots, not replace them with the shipped root. + const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ + default: 'standard', + roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], + includeUserRoot: true, + })])) + expect(config.roots).toEqual([shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }]) + expect(config.default).toBe('standard') + expect(config.includeUserRoot).toBe(true) + }) + + it('derives from the layers each call is given, not from an earlier composition', () => { + // The live user-layer reload calls this per generation: an edited + // cordis.patch.yml must decide the derived roots, never a boot snapshot. + composeProfilePatches([bundleLayer, userLayer({ default: 'standard', roots: [{ path: `${sep}one`, trust: 'user' }] })]) + const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ + default: 'standard', + roots: [{ path: `${sep}two`, trust: 'user' }], + })])) + expect(config.roots).toEqual([shippedRoot, { path: `${sep}two`, trust: 'user' }]) + }) + + it('appends nothing to a composition without the roster row', () => { + const layers = [[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]] + expect(composeProfilePatches(layers)).toEqual(layers.flat()) + }) +}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 0e98af0477..6994cac956 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -12,6 +12,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { composeProfilePatches } from '../src/profile-boot.ts' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -95,18 +96,12 @@ async function bootWeb( { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }, { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' }, ] }, - // The roster AppCLIEntry would patch in; only the shipped root, so a - // developer's own `~/.dsh/.preset` cannot change this test's outcome. + // Pin the roster away from the developer's machine: `includeUserRoot` + // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. - { - id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], - includeUserRoot: false, - }, - }, + // document overrides. No `roots` entry: the launcher's real derivation + // below prepends the shipped root, exactly as `runProfile` composes it. + { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, ...extra, ] // The surface is patch layers over an empty preset root, so the root sits @@ -142,7 +137,10 @@ async function bootWeb( } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { + // The shipped preset root arrives the way the real launcher delivers it: + // derived over these same layers, appended after every override. + const patches = composeProfilePatches([bundlePatches, overrides]) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } @@ -492,10 +490,8 @@ describe('product Bundle and user-preset intersection', () => { id: 'agent-presets', config: { default: 'standard', - roots: [ - { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, - { path: userRoot, trust: 'user' }, - ], + // The shipped root is bootWeb's derivation, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, }, @@ -730,15 +726,11 @@ describe('a launcher that configures no writable root', () => { ) const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') await writeFile(settingsFile, '{}\n') - // Only the shipped root, exactly what `composeProfile` supplies; the + // No configured roots: the shipped one is bootWeb's derivation, and the // writable one is the roster's own default rather than this patch's job. derivedCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], - includeUserRoot: true, - }, + config: { default: 'standard', includeUserRoot: true }, }]) }, 120_000) @@ -781,12 +773,10 @@ describe('authoring a preset on the shipped composition', () => { id: 'agent-presets', config: { default: 'standard', - roots: [ - { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, - // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. - { path: userRoot, trust: 'user' }, - ], + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. The shipped root is bootWeb's + // derivation, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, }]) @@ -893,3 +883,64 @@ describe('a session keeps the preset it was created with', () => { } }) }) + +describe('a composition that configures its own preset roots', () => { + let rootsCtx: Context + let teamRoot: string + + beforeAll(async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-')) + const settingsFile = join(home, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + // A workspace-shared root beside the deployment: one preset of its own, + // plus a directory that claims a shipped id. + teamRoot = join(home, 'team-presets') + const minimalComposition = await readFile(join(CONFIG_DIR, 'agent-presets', 'minimal', 'agent.cordis.yml'), 'utf8') + for (const id of ['team-spec', 'minimal']) { + await mkdir(join(teamRoot, id), { recursive: true }) + await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) + } + // The user layer of the reported regression: a profile's cordis.patch.yml + // configuring a shared preset root. The derivation must EXTEND it with + // the shipped root, never replace it. + rootsCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [{ path: teamRoot, trust: 'user' }], + includeUserRoot: false, + }, + }]) + }, 120_000) + + afterAll(async () => { + await rootsCtx.fiber.dispose() + }) + + it('keeps configured roots alongside the always-prepended shipped root', async () => { + expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ + expect.stringContaining(join('config', 'agent-presets')), + teamRoot, + ]) + + const listed = await rootsCtx.agentPresets.list() + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec']) + expect(listed.every(preset => preset.broken === undefined)).toBe(true) + // The shipped root comes first: a configured directory claiming a shipped + // id is shadowed, never the other way around. + expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system') + expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user') + }) + + it('composes an agent from a configured-root preset', async () => { + const handle = await rootsCtx.agents.create({ + sessionId: SessionId('preset-team-spec'), + setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), + }) + try { + expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) +}) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 61151bdc65..10f826a320 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -434,10 +434,10 @@ # as shell access because a preset IS a composition. # # Only the SHIPPED root is an assembly fact: it sits beside the installed app's -# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the -# same treatment `distIndex` gets on the webserver row. The writable root is -# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that -# never reaches that patch still finds a person's presets. +# own config, so `apps/cli`'s launcher derives a patch per composition that +# PREPENDS it to whatever `roots` the user layers configured here. The writable +# root is `dsh-agent-presets`' own default (`includeUserRoot`), so a +# composition that never reaches that patch still finds a person's presets. - insert: - id: agent-presets name: '@deepseek-ai/dsh-agent-presets' From 25058f2658037eb5fa991a71f7f160201c0ed06e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 21 Aug 2026 11:32:02 +0800 Subject: [PATCH 02/21] docs(cli): sync the derived preset-root layer into launcher docs Review follow-ups: enumerate the derived shipped agent-preset root in apps/cli README/reference dumps and the profile-boot module JSDoc (bilingual pairs re-recorded), correct the stale AppCLIEntry/distIndex analogy in the web scaffold and the shipped-root cross-reference in the web preset e2e, drop the write-only ComposedProfile.rows field, and make the Agent Note describe the dump path as sharing the derivation rather than the builder. --- ...ipped-preset-root-per-composition.i18n.yaml | 4 ++-- ...rive-shipped-preset-root-per-composition.md | 2 +- ...e-shipped-preset-root-per-composition.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 1 + apps/cli/README.zh.md | 1 + apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/profile-boot.ts | 18 +++++++----------- apps/cli/tests/web-agent-presets.e2e.ts | 4 ++-- apps/web/tests/scaffold.ts | 17 +++++++++-------- 12 files changed, 30 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml index f5c3964d7a..26aa999982 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md -2026-08-20-derive-shipped-preset-root-per-composition.md: b303f6a5d08ac2c2ca755d5dbf46eb9a74f5c4ee -2026-08-20-derive-shipped-preset-root-per-composition.zh.md: cc28789898a74df285a96b7e17c35e3b4d12c452 +2026-08-20-derive-shipped-preset-root-per-composition.md: ecf852ade3720cbf5f5f99efa073cc6e3a352fec +2026-08-20-derive-shipped-preset-root-per-composition.zh.md: 718bddd6e4159b32db63262bb40a1e0ce38227ac diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md index b303f6a5d0..ecf852ade3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md @@ -12,7 +12,7 @@ The overlay also sat in `ComposedProfile.overlays`, the fixed top layers a live ## Decision -The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the one builder boot, the live user-layer reloads, and the config dump all go through — a reload derives from the current user layers instead of replaying a boot snapshot, and the dump now renders the derived layer (labeled `dsh launcher (shipped agent-preset root)`) so it composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. +The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the builder boot and the live user-layer reloads share — a reload derives from the current user layers instead of replaying a boot snapshot. The config dump shares the derivation rather than the builder: `renderConfigDump` needs one labeled layer per source, so `runDumpConfig` appends `resolveShippedPresetPatch`'s output as its own layer (labeled `dsh launcher (shipped agent-preset root)`) and composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. A `roots` value the launcher cannot statically rewrite — a `!!js` expression or any non-array — now fails loud with a `TypeError` naming the constraint, instead of being silently replaced. The plugin's own contract is untouched: `config.roots` scanned in order, the writable home root appended by `dsh-agent-presets` itself. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md index cc28789898..718bddd6e4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动、用户层热重载与配置 dump 共同经过的唯一构建器——热重载从当前用户层派生而非重放启动快照,dump 也渲染这个派生层(标注为 `dsh launcher (shipped agent-preset root)`),使 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 +内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动与用户层热重载共用的构建器——热重载从当前用户层派生而非重放启动快照。配置 dump 共用的是派生本身而非构建器:`renderConfigDump` 需要逐层标注来源,所以 `runDumpConfig` 把 `resolveShippedPresetPatch` 的输出作为独立一层追加(标注为 `dsh launcher (shipped agent-preset root)`),对 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 启动器无法静态改写的 `roots` 值——`!!js` 表达式或任何非数组——现在以指明约束的 `TypeError` 大声失败,而不是被静默替换。插件自身的契约不变:`config.roots` 按序扫描,可写 home 根由 `dsh-agent-presets` 自己追加。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index fbea2bc740..c33c75f2cb 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 apps/cli/README.md -README.md: 9a8d722b044ed5d8e31e3c27e54f8c9ef0839f82 -README.zh.md: c092414e2d15e90133d8ea27f0af5cadbe527b22 +README.md: ae6f4c38eee402bcc1a86ba34778afd06da749b1 +README.zh.md: d4661476e4310b987b22fec7a7d417b9ea6811ab diff --git a/apps/cli/README.md b/apps/cli/README.md index 9a8d722b04..ae6f4c38ee 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,6 +35,7 @@ The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays +- then, when the composition mounts the preset roster, a launcher-derived patch that prepends the shipped agent-preset root to the configured `roots` Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index c092414e2d..d4661476e4 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -37,6 +37,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 - `dsh.profile.bundles` 中各组合包的 patch - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 +- 组合挂载预设 roster 时,启动器再派生一个补丁,把内置 agent-preset 根目录前置到已配置的 `roots` 之前 `dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 117c2c6aac..0d5bb37319 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -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 apps/cli/reference/README.md -README.md: dfddd177a78c348793d3e5c2d290fa62c5ac850b -README.zh.md: 8e7508b4b8fcbd39e15538c6ee88733bfa9905f1 +README.md: 2e8c262f5c1e7045472eaaa53657e9b9a7bab9a4 +README.zh.md: 27a63337b6166d3aa560a2205912575a007b0c72 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dfddd177a7..2e8c262f5c 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. When the composition mounts the preset roster, both also append the launcher-derived `dsh launcher (shipped agent-preset root)` layer, so the dump composes that row exactly as boot does. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 8e7508b4b8..27a63337b6 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。当组合挂载预设 roster 时,两者还会追加启动器派生的 `dsh launcher (shipped agent-preset root)` 层,因此 dump 对该行的组合与实际启动完全一致。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index bdb11462bd..68ce0d9749 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,9 +1,10 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's - * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the - * tree over the profile's empty root config, keep the profile patch layer - * live, and wire fail-loud plus bounded shutdown. + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch, and the + * per-composition derived shipped agent-preset root), mount the tree over the + * profile's empty root config, keep the profile patch layer live, and wire + * fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments * are provided to the tree through `ctx.cmdlineArgs`, where any injected app @@ -102,7 +103,7 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** One profile's patch layers (application order) and the row index of its pre-flag composition. */ +/** One profile's patch layers, in application order. */ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ @@ -111,11 +112,6 @@ interface ComposedProfile { homePatches: PatchOptions[] /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ overlays: PatchOptions[] - /** - * id → row of the composed tree (bundles + user layers + overlays), for the - * launcher's own row checks. - */ - rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -199,7 +195,7 @@ export function composeProfilePatches(layers: readonly PatchOptions[][]): PatchO * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @returns the profile, its patch layers, and the composed row index. + * @returns the profile and its patch layers. */ function composeProfile( name: string, @@ -216,7 +212,7 @@ function composeProfile( const composedOverlays = [...overlays] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } + return { profile, bundlePatches, homePatches, overlays: composedOverlays } } /** Options for {@link runProfile}. */ diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6994cac956..f448586279 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -57,8 +57,8 @@ async function bootWeb( // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it // reads the developer's own document — and since the default preset is a // setting, a stored `agent-presets.default` would decide this file's - // outcome. Point it at a temp file for the same reason the roster below - // names only the shipped root. + // outcome. Point it at a temp file for the same reason the roster row + // below pins `includeUserRoot` off. { id: 'settings', config: { path: settingsFile, watch: false } }, // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this // file writes the developer's own `~/.dsh/storages/` — and then reads it diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 83334e13d3..6ecef055ce 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -401,14 +401,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Fri, 21 Aug 2026 12:37:57 +0800 Subject: [PATCH 03/21] refactor(preset): bundle the shipped presets inside dsh-agent-presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked why the launcher special-cases one plugin's row. It no longer does: the four shipped compositions move into the package (presets/, in files), dsh-agent-presets resolves its own shipped root and prepends it before configured roots (includeShippedRoot, default true, opt-out for bare-machinery embedders), and the per-composition derived patch, its spec, and the dump layer are deleted — profile-boot and dump-config return to plain layer stacking. The always-load guarantee now rides the schema default instead of patch ordering, so a whole-config replacement keeps the shipped set and the squash, reload freeze, and dump divergence stop being possible. Gate globs, the web scaffold, and both preset browser lanes drop their hand-fed shipped roots; the roster e2e keeps asserting configured roots beside the shipped four against the built lib. Fixes #2863. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 2 +- ...2026-08-03-per-session-agent-presets.zh.md | 2 +- ...ive-shipped-preset-root-per-composition.md | 31 ----- ...-shipped-preset-root-per-composition.zh.md | 31 ----- ...lugin-owned-shipped-preset-root.i18n.yaml} | 6 +- ...-08-20-plugin-owned-shipped-preset-root.md | 33 ++++++ ...-20-plugin-owned-shipped-preset-root.zh.md | 33 ++++++ ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 1 - apps/cli/README.zh.md | 1 - apps/cli/package.json | 3 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/dump-config.ts | 14 +-- apps/cli/src/profile-boot.ts | 109 ++++-------------- apps/cli/tests/built-bin.e2e.ts | 13 --- apps/cli/tests/shipped-preset-root.spec.ts | 89 -------------- apps/cli/tests/web-agent-presets.e2e.ts | 35 +++--- apps/cli/tests/windows-shell.spec.ts | 5 +- apps/web/tests/agent-preset-authoring.e2e.ts | 10 +- apps/web/tests/agent-preset-selection.e2e.ts | 14 +-- apps/web/tests/scaffold.ts | 29 ++--- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- examples/acp-agent/tests/acp.snapshot.ts | 2 +- packages/bundle/web-app/cordis.patch.yml | 17 ++- packages/preset/README.i18n.yaml | 4 +- packages/preset/README.md | 2 +- packages/preset/README.zh.md | 2 +- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 7 +- packages/preset/agent-presets/README.zh.md | 7 +- packages/preset/agent-presets/package.json | 3 +- .../presets}/code/agent.cordis.yml | 0 .../agent-presets/presets}/code/preset.yml | 0 .../presets}/cordis/agent.cordis.yml | 0 .../agent-presets/presets}/cordis/preset.yml | 0 .../skills/cordis-plugin-development/SKILL.md | 0 .../editing-cordis-compositions/SKILL.md | 0 .../presets}/minimal/agent.cordis.yml | 0 .../agent-presets/presets}/minimal/preset.yml | 0 .../presets}/standard/agent.cordis.yml | 0 .../presets}/standard/preset.yml | 0 .../preset/agent-presets/src/discovery.ts | 17 ++- packages/preset/agent-presets/src/index.ts | 32 ++--- packages/preset/agent-presets/src/preset.ts | 10 +- .../agent-presets/tests/authoring.spec.ts | 11 +- .../agent-presets/tests/invariant.spec.ts | 2 +- .../preset/agent-presets/tests/mount.spec.ts | 16 +-- .../agent-presets/tests/settings.spec.ts | 2 +- .../agent-presets/tests/shipped-root.spec.ts | 90 +++++++++++++++ .../agent-presets/tests/user-root.spec.ts | 2 + .../tests/preset-inheritance.spec.ts | 2 +- scripts/rescope-vendor.ts | 8 +- scripts/verify-cordis-config.ts | 2 +- scripts/verify-runtime-closure.spec.ts | 14 +-- scripts/verify-runtime-closure.ts | 2 +- 66 files changed, 358 insertions(+), 417 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md rename .agents/notes/implemented/bug-fix/{2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml => 2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml} (52%) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md delete mode 100644 apps/cli/tests/shipped-preset-root.spec.ts rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/code/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/code/preset.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/preset.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/skills/cordis-plugin-development/SKILL.md (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/cordis/skills/editing-cordis-compositions/SKILL.md (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/minimal/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/minimal/preset.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/standard/agent.cordis.yml (100%) rename {apps/cli/config/agent-presets => packages/preset/agent-presets/presets}/standard/preset.yml (100%) create mode 100644 packages/preset/agent-presets/tests/shipped-root.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index b8bcc4246e..02e3efff43 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab +2026-07-10-single-file-executable-sdk-runtime-distribution.md: cc62ed280ff354073bab10646bdbf8331a81bc06 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 89f6ab43315ba22f6ff09368f442424284ae9ee9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 40433d99e5..cc62ed280f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cff72ae10e..89f6ab4331 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 6d956d86ec..1d6c5dcae9 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 5a82f0220058c10892b819a83499c817aa9be6ad -2026-08-03-per-session-agent-presets.zh.md: 0adcfec8b2c39a1f97d74edfa784f45062b52a99 +2026-08-03-per-session-agent-presets.md: f21620b9cd67bbb9f73317c3ddaa4926331393cc +2026-08-03-per-session-agent-presets.zh.md: 9d58a235bd55e88644b0e59a3a5c92863469b583 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 5a82f02200..f21620b9cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -23,7 +23,7 @@ Composition splits into two planes, decided by what must be shared rather than b Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. -The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here. +The presets the deployment ships are the directories under `packages/preset/agent-presets/presets/`; the roster is that listing, not a list restated here. Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 0adcfec8b2..9d58a235bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -23,7 +23,7 @@ Status: implemented 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 -部署交付哪些 preset,取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 +部署交付哪些 preset,取决于 `packages/preset/agent-presets/presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md deleted file mode 100644 index ecf852ade3..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Derive the shipped preset root per composition - -Status: implemented - -English | [中文](2026-08-20-derive-shipped-preset-root-per-composition.zh.md) - -## Problem - -`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's own writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them — the include's own contract that a dump can never drift from what boots was broken by a patch the dump never saw. Externally reported with an accurate root cause in discussion #3636. - -The overlay also sat in `ComposedProfile.overlays`, the fixed top layers a live reload replays above fresh user layers. Overlays exist so a user edit cannot displace launcher facts, which is right for `--patch` files and the telemetry switch — but the roster patch had captured the whole boot-time `config`, so after boot no `cordis.patch.yml` edit to the row (`default`, `includeUserRoot`, `roots`) could take effect until restart. - -## Decision - -The shipped root is a derivation, not an overlay. `resolveShippedPresetPatch(rows)` builds the roster patch from one composed row set: it keeps every configured key and prepends the shipped root (`system` trust) to the composition's `roots`, so the shipped presets always mount and win a duplicate id while configured roots stay live. `composeProfilePatches(layers)` appends that patch to the flattened stack and is the builder boot and the live user-layer reloads share — a reload derives from the current user layers instead of replaying a boot snapshot. The config dump shares the derivation rather than the builder: `renderConfigDump` needs one labeled layer per source, so `runDumpConfig` appends `resolveShippedPresetPatch`'s output as its own layer (labeled `dsh launcher (shipped agent-preset root)`) and composes the roster row exactly as it boots. The telemetry switch stays a boot-only overlay: it is an environment fact of the booting process, carries no config snapshot, and outranking user edits is its purpose. - -A `roots` value the launcher cannot statically rewrite — a `!!js` expression or any non-array — now fails loud with a `TypeError` naming the constraint, instead of being silently replaced. The plugin's own contract is untouched: `config.roots` scanned in order, the writable home root appended by `dsh-agent-presets` itself. - -## Testing - -`shipped-preset-root.spec.ts` covers the derivation directly: prepend order, key preservation, absence without a roster row, per-call derivation, the fail-loud rejections, and the squash regression through a full `composeEntries` application. The Web composition e2e now obtains the shipped root through the real `composeProfilePatches` instead of hand-writing the launcher's patch (three boots had replicated it literally, one admitting "exactly what `composeProfile` supplies"), and adds a configured-roots boot: a shared root's preset lists beside the shipped four, a directory claiming a shipped id is shadowed by it, and a configured-root preset composes an agent. The built-bin dump acceptance asserts the derived layer's label and the shipped-before-configured root order. No keyless snapshot changes: default compositions produce byte-identical stacks, and the snapshot harness has no custom-profile lane — the real-composition e2e is the assembled-application evidence here. - -## Alternatives considered - -**The reporter's fix: prepend inside the boot-time overlay.** Correct on the squash and the priority order, and kept as the shape of the derived patch. Rejected as-is because the overlay would still freeze the whole boot-time `config` above every later reload, leaving the row's live edits dead until restart. - -**Provide the shipped root out of band (a launcher-provided context value the plugin prepends).** Cleanest hot-reload story — no config rewriting at all — but it moves an assembly fact into the plugin's service contract, adds a launcher-coupled provide key to a package that otherwise only reads config, and makes the effective roots invisible to the config dump. The derived patch keeps the roster's inputs entirely in the composition. - -## Consequences - -Configured preset roots survive boot, live edits to the roster row take effect without restart, and the dump, the live tree, and the boot compose the row identically. The launcher constrains the roster row's `config`/`roots` to literal values; a composition that generated them with `!!js` would previously have had the expression silently discarded and now must materialize the array in a patch layer instead. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md deleted file mode 100644 index 718bddd6e4..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Derive the shipped preset root per composition - -Status: implemented - -[English](2026-08-20-derive-shipped-preset-root-per-composition.md) | 中文 - -## 问题 - -`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁会整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 自己的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,所以 dump 显示配置的根目录完好而启动却丢弃了它们——include 自身"dump 永不偏离实际启动"的契约,被一个 dump 看不到的补丁打破。外部报告 discussion #3636 给出了准确的根因。 - -该 overlay 还位于 `ComposedProfile.overlays`——热重载在新鲜用户层之上重放的固定顶层。overlay 的存在意义是让用户编辑无法顶掉启动器事实,这对 `--patch` 文件和遥测开关是正确的——但 roster 补丁快照了启动时的整个 `config`,导致启动后对该行的任何 `cordis.patch.yml` 编辑(`default`、`includeUserRoot`、`roots`)在重启前都不生效。 - -## 决定 - -内置根是一个派生,不是一个 overlay。`resolveShippedPresetPatch(rows)` 从一份已组合的行集构建 roster 补丁:保留全部已配置的键,并把内置根(`system` 信任)前置到组合的 `roots` 中,因此内置 preset 始终挂载并在 id 冲突时胜出,而配置的根目录保持生效。`composeProfilePatches(layers)` 把该补丁追加到展平后的补丁栈,是启动与用户层热重载共用的构建器——热重载从当前用户层派生而非重放启动快照。配置 dump 共用的是派生本身而非构建器:`renderConfigDump` 需要逐层标注来源,所以 `runDumpConfig` 把 `resolveShippedPresetPatch` 的输出作为独立一层追加(标注为 `dsh launcher (shipped agent-preset root)`),对 roster 行的组合与实际启动完全一致。遥测开关仍是仅启动时的 overlay:它是启动进程的环境事实,不携带 config 快照,压过用户编辑正是其目的。 - -启动器无法静态改写的 `roots` 值——`!!js` 表达式或任何非数组——现在以指明约束的 `TypeError` 大声失败,而不是被静默替换。插件自身的契约不变:`config.roots` 按序扫描,可写 home 根由 `dsh-agent-presets` 自己追加。 - -## 测试 - -`shipped-preset-root.spec.ts` 直接覆盖派生逻辑:前置顺序、键保留、无 roster 行时不产出、逐次调用派生、大声失败的拒绝分支,以及经完整 `composeEntries` 应用验证的压掉回归。Web 组合 e2e 现在通过真实的 `composeProfilePatches` 获得内置根,不再手抄启动器补丁(此前三处启动逐字复制了它,其中一处自述"exactly what `composeProfile` supplies"),并新增配置根目录的启动场景:共享根的 preset 与内置四个并列出现、占用内置 id 的目录被其遮蔽、配置根中的 preset 能组合出 agent。built-bin dump 验收断言派生层标签及"内置根在配置根之前"的顺序。无 keyless 快照变更:默认组合产生的补丁栈逐字节相同,且快照框架没有自定义 profile 通道——真实组合 e2e 即是组装应用层面的证据。 - -## 曾考虑的替代方案 - -**报告者的修法:在启动时 overlay 内部做前置。** 对压掉问题与优先级顺序判断正确,派生补丁保留了这一形状。按原样采纳被否,因为该 overlay 仍会把启动时的整个 `config` 冻结在所有后续重载之上,该行的实时编辑在重启前依然失效。 - -**带外提供内置根(启动器提供的上下文值,由插件前置)。** 热重载故事最干净——完全不改写 config——但它把装配事实挪进插件的服务契约,给一个本只读 config 的包加上与启动器耦合的 provide 键,还让有效根目录对配置 dump 不可见。派生补丁把 roster 的输入完整留在组合之内。 - -## 后果 - -配置的 preset 根目录在启动后存活,对 roster 行的实时编辑无需重启即生效,dump、活动树与启动对该行的组合完全一致。启动器将 roster 行的 `config`/`roots` 约束为字面量;此前用 `!!js` 生成它们的组合本来就会被静默丢弃表达式,现在必须在某个补丁层实体化该数组。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml index 26aa999982..a25b27d6de 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-derive-shipped-preset-root-per-composition.md -2026-08-20-derive-shipped-preset-root-per-composition.md: ecf852ade3720cbf5f5f99efa073cc6e3a352fec -2026-08-20-derive-shipped-preset-root-per-composition.zh.md: 718bddd6e4159b32db63262bb40a1e0ce38227ac +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md +2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d +2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md new file mode 100644 index 0000000000..43bcc685c2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md @@ -0,0 +1,33 @@ +# Agent Note: The shipped preset root is the plugin's own + +Status: implemented + +English | [中文](2026-08-20-plugin-owned-shipped-preset-root.zh.md) + +## Problem + +`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them. The overlay also froze the row's boot-time `config` above every live reload, so no `cordis.patch.yml` edit to the row took effect until restart. Externally reported with an accurate root cause in discussion #3636. + +Under the whole-`config`-replacement patch semantics, any "must survive user layers" value needs enforcement after composition — and review rejected keeping that enforcement in the launcher: `apps/cli` special-casing one plugin's row id, config keys, and precedence is coupling the composition machinery should not carry. + +## Decision + +The shipped presets are the plugin's own. The four built-in compositions moved from `apps/cli/config/agent-presets/` into `packages/preset/agent-presets/presets/`, listed in the package's `files`, and `dsh-agent-presets` resolves `SHIPPED_PRESET_ROOT` relative to its own module — the Loader imports the plugin by package name at runtime, so the directory exists on disk in both the source and installed layouts, the same mechanism that lets the `cordis` preset carry its skills inside its directory. `resolvedRoots` becomes shipped root (`system` trust) unless `includeShippedRoot` is false, then `config.roots` in order, then the derived writable home root unless `includeUserRoot` is false — prepended, so the shipped set always mounts and wins a duplicate id. + +This completes the [per-session preset roster](../architecture/2026-08-03-per-session-agent-presets.md) direction that #2278 started for the writable root: both non-configured roots are now the package's, the launcher composes patch layers with no plugin knowledge, and the squash, the reload freeze, and the dump divergence stop being possible rather than being corrected. The always-load guarantee no longer rides patch ordering: `includeShippedRoot` defaults true in the schema, so a user layer replacing the row's whole `config` keeps the shipped set, and only an explicit `false` — as deliberate as disabling the row — drops it. The compositions bind to the host's agent-plane services, not to the Web surface: no preset row names a client or web plugin, and a host lacking an injected service leaves that row waiting exactly as under any other root. + +## Testing + +`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location. + +## Alternatives considered + +**Keep the launcher patch but derive it per composition, prepending instead of replacing.** The first merged-nowhere iteration of this fix: correct on the squash, the reload freeze, and the dump (which gained the derived layer as a labeled dump layer), with the reporter's overlay-prepend shape as its core. Superseded in review because every variant keeps `apps/cli` special-casing the roster row; the coupling, not the mechanics, was the objection. + +**Have the bundle declare the shipped root itself (`!!js` package-relative path).** Removes the launcher coupling but hangs the always-load guarantee back on patch ordering: a user layer replacing the row's `config` drops the bundle's entry — the reported bug's shape again. + +**Provide the root out of band (a launcher-provided context value the plugin prepends).** The launcher still has to know to provide a preset fact; the special case survives in a different channel. + +## Consequences + +`config.roots` is purely deployment-added directories; the dump shows exactly that, and the shipped root is documented plugin behavior surfaced at runtime through `agentPresets.roots`. `apps/cli` ships no `config/` directory and its `files` entry is gone. Any composition that mounts the roster — and any embedder of the package — gets the shipped set by default and turns it off with one config line; embedders wanting bare machinery set `includeShippedRoot: false`. The presets' bare plugin names still resolve through the boot's flat installation fallback, unchanged by the move. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md new file mode 100644 index 0000000000..c2cc586a7a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md @@ -0,0 +1,33 @@ +# Agent Note: The shipped preset root is the plugin's own + +Status: implemented + +[English](2026-08-20-plugin-owned-shipped-preset-root.md) | 中文 + +## 问题 + +`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,dump 显示配置的根目录完好而启动却丢弃了它们。该 overlay 还把行的启动时 `config` 冻结在所有热重载之上,重启前对该行的任何 `cordis.patch.yml` 编辑都不生效。外部报告 discussion #3636 给出了准确根因。 + +在"补丁整体替换 `config`"的语义下,任何"必须在用户层之后存活"的值都需要组合后的强制注入——而评审否决了把这份强制留在启动器里:`apps/cli` 对某一个插件的行 id、config 键与优先级做特判,是组合机器不应携带的耦合。 + +## 决定 + +内置 preset 归插件自有。四套内置组合从 `apps/cli/config/agent-presets/` 搬入 `packages/preset/agent-presets/presets/`,列入包的 `files`;`dsh-agent-presets` 相对自己的模块解析 `SHIPPED_PRESET_ROOT`——Loader 在运行时按包名导入插件,目录在源码与安装两种布局中都真实存在于磁盘上,与 `cordis` preset 目录内随行携带 skill 依赖的是同一机制。`resolvedRoots` 变为:除非 `includeShippedRoot` 为 false,先是内置根(`system` 信任),再按序 `config.roots`,最后除非 `includeUserRoot` 为 false 追加推导的可写 home 根——前置,因此内置集合始终挂载并赢得重复 id。 + +这补全了 #2278 为可写根开启的[会话级 preset roster](../architecture/2026-08-03-per-session-agent-presets.zh.md) 方向:两个非配置根现在都属于本包,启动器不带任何插件知识地组合补丁层,压掉、重载冻结与 dump 分叉从"被修复"变为"不再可能发生"。"一定加载"的保证不再依赖补丁顺序:`includeShippedRoot` 在 schema 中默认 true,用户层整体替换该行 `config` 后内置集合依然保留,只有显式 `false`——与整行 disable 同级的故意行为——才会去掉它。组合绑定的是宿主的 agent-plane 服务而非 Web 表面:没有任何 preset 行引用 client 或 web 插件;宿主缺少被注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。 + +## 测试 + +`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。 + +## 曾考虑的替代方案 + +**保留启动器补丁但按组合派生、前置而非替换。** 本修复未曾合入的第一版:对压掉、重载冻结与 dump(曾以带标签层渲染派生补丁)判断均正确,核心即报告者的 overlay 前置形状。在评审中被替代,因为每个变体都让 `apps/cli` 对 roster 行做特判;被否决的是耦合而非机制。 + +**由 bundle 自己声明内置根(`!!js` 包相对路径)。** 去掉启动器耦合,但把"一定加载"的保证重新挂回补丁顺序:用户层整体替换该行 `config` 时 bundle 的条目被丢弃——又回到所报 bug 的形状。 + +**带外提供根目录(启动器提供的上下文值,由插件前置)。** 启动器仍需知道"要为 preset 提供一个事实";特判换了通道继续存在。 + +## 后果 + +`config.roots` 纯粹是部署追加的目录;dump 展示的正是它,内置根成为文档化的插件行为,运行时经 `agentPresets.roots` 呈现。`apps/cli` 不再携带 `config/` 目录,其 `files` 条目移除。任何挂载 roster 的组合——以及任何嵌入本包的使用方——默认获得内置集合,一行配置即可关闭;只要纯机制的嵌入方设 `includeShippedRoot: false`。preset 里的裸插件名仍经启动的扁平安装后备解析,搬移不改变这一点。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index ab658f689f..df7b3d700b 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 982b0b87553e15fab8fd6a2718dbe2462cfb3bd9 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 05935f8164018d0c476f85ccc5e6c0e87890f979 +2026-07-29-persistent-bash-str-replace-editor.md: e8e37b7e534773429a9c6fe0f63bb8d5460de364 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 71034ba615e09e09ec03212b6d5535959df73f4a diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 982b0b8755..e8e37b7e53 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. +The shipped [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 05935f8164..71034ba615 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。 +随附的 [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。 ## 考虑过的替代方案 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c33c75f2cb..fbea2bc740 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -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 apps/cli/README.md -README.md: ae6f4c38eee402bcc1a86ba34778afd06da749b1 -README.zh.md: d4661476e4310b987b22fec7a7d417b9ea6811ab +README.md: 9a8d722b044ed5d8e31e3c27e54f8c9ef0839f82 +README.zh.md: c092414e2d15e90133d8ea27f0af5cadbe527b22 diff --git a/apps/cli/README.md b/apps/cli/README.md index ae6f4c38ee..9a8d722b04 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,7 +35,6 @@ The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays -- then, when the composition mounts the preset roster, a launcher-derived patch that prepends the shipped agent-preset root to the configured `roots` Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index d4661476e4..c092414e2d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -37,7 +37,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 - `dsh.profile.bundles` 中各组合包的 patch - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 -- 组合挂载预设 roster 时,启动器再派生一个补丁,把内置 agent-preset 根目录前置到已配置的 `roots` 之前 `dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 30e6f5da57..209411aaf2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,8 +15,7 @@ "dsh": "lib/bin.js" }, "files": [ - "lib/*.js", - "config" + "lib/*.js" ], "license": "MIT", "dependencies": { diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0d5bb37319..117c2c6aac 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -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 apps/cli/reference/README.md -README.md: 2e8c262f5c1e7045472eaaa53657e9b9a7bab9a4 -README.zh.md: 27a63337b6166d3aa560a2205912575a007b0c72 +README.md: dfddd177a78c348793d3e5c2d290fa62c5ac850b +README.zh.md: 8e7508b4b8fcbd39e15538c6ee88733bfa9905f1 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 2e8c262f5c..dfddd177a7 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. When the composition mounts the preset roster, both also append the launcher-derived `dsh launcher (shipped agent-preset root)` layer, so the dump composes that row exactly as boot does. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 27a63337b6..8e7508b4b8 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -36,7 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。当组合挂载预设 roster 时,两者还会追加启动器派生的 `dsh launcher (shipped agent-preset root)` 层,因此 dump 对该行的组合与实际启动完全一致。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 229a4c67ab..1754eb4efd 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -2,8 +2,7 @@ * Config-dump entry for `dsh --profile --dump-config`: compose the * profile's patch layers through the include plugin's patch algorithm without * booting or evaluating `!!js`, with one source layer per bundle, the - * profile's own patch file, each `--patch` overlay, and the launcher-derived - * shipped agent-preset root. + * profile's own patch file, and each `--patch` overlay. * @module @deepseek-ai/dsh/dump-config */ @@ -15,7 +14,7 @@ import { renderConfigDump, type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' -import { composeRows, homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME, resolveShippedPresetPatch } from './profile-boot.ts' +import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' const NAME = 'dsh' @@ -48,15 +47,6 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) }) } } - // The launcher derives one more layer no file carries: the shipped - // agent-preset root, prepended to whatever roots the layers configured. - // Included so the dump composes the roster row exactly as it boots. (The - // telemetry hard-disable switch stays out: it is an environment fact of the - // booting process, not part of the profile composition.) - const presetPatch = resolveShippedPresetPatch(composeRows(layers.map(layer => layer.patches))) - if (presetPatch !== undefined) { - layers.push({ label: `${NAME} launcher (shipped agent-preset root)`, patches: [presetPatch] }) - } // The dump anchors on the same empty root file the boot includes. process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers)) } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 68ce0d9749..ac5d7e83ec 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,10 +1,9 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's - * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch, and the - * per-composition derived shipped agent-preset root), mount the tree over the - * profile's empty root config, keep the profile patch layer live, and wire - * fail-loud plus bounded shutdown. + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments * are provided to the tree through `ctx.cmdlineArgs`, where any injected app @@ -17,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -31,10 +30,6 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' - -/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ -const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) - import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -116,74 +111,12 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return composeProfilePatches([ - composed.bundlePatches, - composed.profile.patches, - composed.homePatches, - composed.overlays, - ]) -} - -/** - * Compose patch layers and index the resulting rows by id. - * @param layers - patch lists in application order. - * @returns id → composed row, for rows that carry a string id. - */ -export function composeRows(layers: readonly PatchOptions[][]): Map { - const rows = new Map() - for (const row of composeEntries(layers)) { - if (typeof row.id === 'string') rows.set(row.id, row) - } - return rows -} - -/** - * Derive the shipped agent-preset-root patch from one composed row set. The - * shipped root is the part of the roster only this app can resolve: it sits - * beside this app's own config, in both the source and built layouts. The - * derived patch keeps every configured key and PREPENDS the shipped root to - * the composition's `roots`, so the shipped presets always mount and win a - * duplicate id while configured roots stay live. (The writable root the - * roster appends is `dsh-agent-presets`' own, so a launcher that never - * reaches this patch still finds a person's presets.) - * @param rows - id → row of the composed tree the patch applies over. - * @returns the roster patch, or `undefined` when the composition has no roster row. - * @throws TypeError when the composed row's config or its `roots` is not a - * literal the launcher can rewrite (a `!!js` expression or a non-array value). - */ -export function resolveShippedPresetPatch(rows: ReadonlyMap): PatchOptions | undefined { - const row = rows.get('agent-presets') - if (row === undefined) return undefined - const config: unknown = row.config ?? {} - if (typeof config !== 'object' || config === null || Array.isArray(config) || isJsExpr(config)) { - throw new TypeError(`${NAME}: agent-presets config must be a literal mapping — the launcher prepends the shipped preset root into it`) - } - const configured = (config as Record).roots ?? [] - if (!Array.isArray(configured)) { - throw new TypeError(`${NAME}: agent-presets config.roots must be a literal array — the launcher prepends the shipped preset root into it`) - } - const configuredRoots: readonly unknown[] = configured - return { - id: 'agent-presets', - config: { - ...(config as Record), - roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }, ...configuredRoots], - }, - } -} - -/** - * Compose one generation's full patch stack: the layers in application order, - * then the shipped preset-root patch derived from their composition. Shared - * by boot and the live user-layer reloads, so a reload derives the roster - * from the CURRENT user layers instead of replaying a boot-time snapshot — - * an edit to the row's config, `roots` included, keeps taking effect. - * @param layers - patch lists in application order. - * @returns the flattened stack with the derived roster patch appended. - */ -export function composeProfilePatches(layers: readonly PatchOptions[][]): PatchOptions[] { - const presetPatch = resolveShippedPresetPatch(composeRows(layers)) - return [...layers.flat(), ...presetPatch === undefined ? [] : [presetPatch]] + return [ + ...composed.bundlePatches, + ...composed.profile.patches, + ...composed.homePatches, + ...composed.overlays, + ] } /** @@ -205,10 +138,10 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) - const rows = composeRows([bundlePatches, profile.patches, homePatches, overlays]) - // The shipped agent-preset root is NOT pushed here: it is derived from the - // current layers on every composition (`composeProfilePatches`), so a live - // user-layer edit to the roster row keeps taking effect. + const rows = new Map() + for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + if (typeof row.id === 'string') rows.set(row.id, row) + } const composedOverlays = [...overlays] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) @@ -282,14 +215,12 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // objects in place. Reusing one parsed patch object across applications // would bake a user override into the bundle's in-memory insert row, so // removing the override could never revert the row to the bundle default. - // The derived shipped-preset patch is recomputed per generation from these - // fresh layers, never carried over from boot. - const composeLive = (): PatchOptions[] => structuredClone(composeProfilePatches([ - composed.bundlePatches, - loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], - loadOptionalPatches(NAME, homePatchPath()) ?? [], - composed.overlays, - ])) + const composeLive = (): PatchOptions[] => structuredClone([ + ...composed.bundlePatches, + ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], + ...loadOptionalPatches(NAME, homePatchPath()) ?? [], + ...composed.overlays, + ]) // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a2c4fa97c0..75ab640fcc 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -748,12 +748,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ' - id: personal', ' provider: personal-provider', ' model: personal-model', - '- id: agent-presets', - ' config:', - ' default: standard', - ' roots:', - ` - path: ${join(home, 'team-presets')}`, - ' trust: user', '- id: absent-row', ' config:', ' x: 1', @@ -778,13 +772,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toContain('personal-provider') // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) - // The dump composes the launcher-derived roster layer too: the shipped - // preset root is prepended to the user layer's roots, not replacing them. - expect(stdout).toContain('dsh launcher (shipped agent-preset root)') - const shippedRootAt = stdout.search(/config[\\/]+agent-presets/) - const configuredRootAt = stdout.search(/team-presets/) - expect(shippedRootAt).toBeGreaterThanOrEqual(0) - expect(configuredRootAt).toBeGreaterThan(shippedRootAt) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) }) diff --git a/apps/cli/tests/shipped-preset-root.spec.ts b/apps/cli/tests/shipped-preset-root.spec.ts deleted file mode 100644 index 3f43daa96c..0000000000 --- a/apps/cli/tests/shipped-preset-root.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { sep } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { composeEntries } from '@deepseek-ai/dsh-app-boot' -import { composeProfilePatches, composeRows, resolveShippedPresetPatch } from '../src/profile-boot.ts' - -/** The web bundle's roster insert, reduced to the keys the derivation reads. */ -const bundleLayer: PatchOptions[] = [{ - insert: [{ id: 'agent-presets', name: '@deepseek-ai/dsh-agent-presets', config: { default: 'standard' } }], -}] - -const userLayer = (config: Record): PatchOptions[] => [{ id: 'agent-presets', config }] - -const shippedRoot = { path: expect.stringContaining(`config${sep}agent-presets`) as unknown, trust: 'system' } - -/** Apply a full patch stack the way boot does and return the roster row's mounted config. */ -function finalRosterConfig(patches: PatchOptions[]): Record { - const row = composeEntries([patches]).find(entry => entry.id === 'agent-presets') - if (row === undefined) throw new Error('missing agent-presets row') - return row.config as Record -} - -describe('resolveShippedPresetPatch', () => { - it('is absent for a composition without the roster row', () => { - const rows = composeRows([[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]]) - expect(resolveShippedPresetPatch(rows)).toBeUndefined() - }) - - it('prepends the shipped root to configured roots and preserves every other key', () => { - const rows = composeRows([bundleLayer, userLayer({ - default: 'minimal', - roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], - includeUserRoot: false, - })]) - expect(resolveShippedPresetPatch(rows)).toEqual({ - id: 'agent-presets', - config: { - default: 'minimal', - includeUserRoot: false, - roots: [shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }], - }, - }) - }) - - it('supplies the shipped root alone when the composition configures none', () => { - const patch = resolveShippedPresetPatch(composeRows([bundleLayer])) - expect(patch).toEqual({ id: 'agent-presets', config: { default: 'standard', roots: [shippedRoot] } }) - }) - - it('fails loud on a config it cannot statically rewrite', () => { - expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: 'nope' })]))) - .toThrow(TypeError) - expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, userLayer({ default: 'standard', roots: { __jsExpr: 'x' } })]))) - .toThrow(/literal array/) - expect(() => resolveShippedPresetPatch(composeRows([bundleLayer, [{ id: 'agent-presets', config: { __jsExpr: 'x' } }]]))) - .toThrow(/literal mapping/) - }) -}) - -describe('composeProfilePatches', () => { - it('keeps configured roots effective through the whole patch application', () => { - // The squash this stack exists to prevent: the derived patch must extend - // the user layer's roots, not replace them with the shipped root. - const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ - default: 'standard', - roots: [{ path: `${sep}shared${sep}presets`, trust: 'user' }], - includeUserRoot: true, - })])) - expect(config.roots).toEqual([shippedRoot, { path: `${sep}shared${sep}presets`, trust: 'user' }]) - expect(config.default).toBe('standard') - expect(config.includeUserRoot).toBe(true) - }) - - it('derives from the layers each call is given, not from an earlier composition', () => { - // The live user-layer reload calls this per generation: an edited - // cordis.patch.yml must decide the derived roots, never a boot snapshot. - composeProfilePatches([bundleLayer, userLayer({ default: 'standard', roots: [{ path: `${sep}one`, trust: 'user' }] })]) - const config = finalRosterConfig(composeProfilePatches([bundleLayer, userLayer({ - default: 'standard', - roots: [{ path: `${sep}two`, trust: 'user' }], - })])) - expect(config.roots).toEqual([shippedRoot, { path: `${sep}two`, trust: 'user' }]) - }) - - it('appends nothing to a composition without the roster row', () => { - const layers = [[{ insert: [{ id: 'other', name: '@deepseek-ai/dsh-other' }] }]] - expect(composeProfilePatches(layers)).toEqual(layers.flat()) - }) -}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index f448586279..879564299a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,8 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' -import { composeProfilePatches } from '../src/profile-boot.ts' +import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -22,7 +21,6 @@ import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-token-meter' -const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') @@ -99,8 +97,8 @@ async function bootWeb( // Pin the roster away from the developer's machine: `includeUserRoot` // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. No `roots` entry: the launcher's real derivation - // below prepends the shipped root, exactly as `runProfile` composes it. + // document overrides. No `roots` entry: the plugin bundles the shipped + // presets itself and prepends their root. { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, ...extra, ] @@ -137,10 +135,7 @@ async function bootWeb( } const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - // The shipped preset root arrives the way the real launcher delivers it: - // derived over these same layers, appended after every override. - const patches = composeProfilePatches([bundlePatches, overrides]) - return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } @@ -363,7 +358,7 @@ describe('the shipped Web composition', () => { // The preset's skill root is derived from its own `baseUrl`, so the skill // travels with the directory wherever the preset is installed. const skill = join( - CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', ) expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) @@ -435,7 +430,7 @@ describe('the shipped Web composition', () => { // agent down disposes its whole subtree. Inherited, that rewrote the // shipped composition — truncating it to `[]` the first time a session // ended — so `PresetTree` refuses to write at all. - const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml') const before = await readFile(path, 'utf8') const handle = await ctx.agents.create({ @@ -463,7 +458,7 @@ describe('product Bundle and user-preset intersection', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) const userRoot = join(root, 'presets') const settingsFile = join(root, 'settings.yaml') - const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8') await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard @@ -490,7 +485,7 @@ describe('product Bundle and user-preset intersection', () => { id: 'agent-presets', config: { default: 'standard', - // The shipped root is bootWeb's derivation, prepended before this. + // The shipped root is the plugin's own, prepended before this. roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, @@ -726,7 +721,7 @@ describe('a launcher that configures no writable root', () => { ) const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') await writeFile(settingsFile, '{}\n') - // No configured roots: the shipped one is bootWeb's derivation, and the + // No configured roots: the shipped one is the plugin's own, and the // writable one is the roster's own default rather than this patch's job. derivedCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', @@ -774,8 +769,8 @@ describe('authoring a preset on the shipped composition', () => { config: { default: 'standard', // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. The shipped root is bootWeb's - // derivation, prepended before this. + // nothing is the normal first-run state. The shipped root is the + // plugin's own, prepended before this. roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, @@ -895,14 +890,14 @@ describe('a composition that configures its own preset roots', () => { // A workspace-shared root beside the deployment: one preset of its own, // plus a directory that claims a shipped id. teamRoot = join(home, 'team-presets') - const minimalComposition = await readFile(join(CONFIG_DIR, 'agent-presets', 'minimal', 'agent.cordis.yml'), 'utf8') + const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8') for (const id of ['team-spec', 'minimal']) { await mkdir(join(teamRoot, id), { recursive: true }) await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) } // The user layer of the reported regression: a profile's cordis.patch.yml - // configuring a shared preset root. The derivation must EXTEND it with - // the shipped root, never replace it. + // configuring a shared preset root. The plugin must EXTEND it with its + // own shipped root, never lose it. rootsCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', config: { @@ -919,7 +914,7 @@ describe('a composition that configures its own preset roots', () => { it('keeps configured roots alongside the always-prepended shipped root', async () => { expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ - expect.stringContaining(join('config', 'agent-presets')), + SHIPPED_PRESET_ROOT, teamRoot, ]) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index ce37022cdf..f94ce582a6 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -13,11 +13,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, rmSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' /** @@ -101,7 +102,7 @@ describe('the shipped shell composition (real bundle layers)', () => { }) describe('shipped agent presets gate both shell tools by platform', () => { - const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets') + const presetRoot = SHIPPED_PRESET_ROOT it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { const entries: unknown = yaml.load( diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 1a27f96c6e..f16f81edfb 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -27,8 +27,8 @@ const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') -/** The shipped roster, beside the composition that names it. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +/** The shipped roster, bundled inside the `dsh-agent-presets` package. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url)) const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() @@ -60,10 +60,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, agentPresets: { - roots: [ - { path: SHIPPED_PRESETS, trust: 'system' }, - { path: userRoot, trust: 'user' }, - ], + // The shipped root is the plugin's own, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], default: 'standard', }, }) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 3d7c10abbc..7153b7ba30 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -1,7 +1,5 @@ -// Web e2e scenario: agent-preset selection. The roster's `roots` is an -// assembly fact the CLI entry resolves and patches in, so every other lane -// boots with an empty roster and no preset surface at all; this is the one -// lane that mounts the SHIPPED presets and puts them in front of a browser. +// Web e2e scenario: agent-preset selection. Every lane mounts the plugin's +// own shipped presets; this is the lane that puts them in front of a browser. // // Two surfaces, one host rule: a session's composition is fixed when the // session starts. Before that, the new-session chip stages the choice beside @@ -30,8 +28,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') -/** The shipped roster, beside the composition that names it. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'agent-preset-selection-web-e2e' /** A project skill only a preset that mounts `skill-filesystem` can discover. */ @@ -172,9 +168,9 @@ describe('web e2e: agent-preset selection', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ - agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' }, - }) + // The scaffold's default roster pin is exactly this scenario's shape: the + // plugin's shipped presets, default `standard`. + scaffold = await launchWebScaffold({}) // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6ecef055ce..5f0aa6f518 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -101,8 +101,6 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the profile module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') -/** The deployment's own agent-preset root, shipped beside the app's config. */ -const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -268,15 +266,14 @@ export interface LaunchOptions { apiKeyEnv: string } /** - * Replace the roster the scaffold mounts by default (the shipped directory - * at `system` trust, default `standard`). Supply this only to change WHICH - * presets a scenario sees — a writable user root, a different default — - * never to turn the roster on: without one every session composes an agent - * with no tools, no persona, and no token meter, which is not a shape the - * product ever boots in. The patch lands after the default, so it wins. + * Replace the roster row the scaffold pins by default (no configured roots, + * default `standard` — the plugin's own shipped presets). Supply this only + * to change WHICH presets a scenario sees beyond the shipped set — a + * writable user root, a different default. The patch lands after the + * default, so it wins. */ agentPresets?: { - /** Roots to discover, in precedence order; the shipped directory is `system`. */ + /** Roots to discover after the plugin's shipped root, in precedence order. */ roots: { path: string; trust: 'system' | 'user' }[] /** The preset a session that names none is composed from. */ default: string @@ -401,20 +398,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. -### The writable root is this package's, the shipped root is the app's +### The shipped and writable roots are this package's + +The shipped presets travel inside this package, beside `lib/`, the way each preset's own skills travel inside its directory. Their root is PREPENDED before every configured root, so the built-in set always mounts and wins a duplicate id — no patch layer replacing the roster row's `config` can accidentally drop it, and the schema default keeps the set through a whole-`config` replacement. The compositions require the host's agent-plane services, not any one surface: a host lacking a service a preset row injects leaves that row waiting, exactly as under any other root. `/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. -`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. +`includeShippedRoot: false` drops the built-in set — for a deployment supplying purely its own presets, or an embedder using the roster as bare machinery. `includeUserRoot: false` drops the derived writable root — for a deployment that confines presets to its own directories. A test pinning an exact roster sets both off; otherwise the package's shipped presets and the machine's real `` decide what the roster contains. The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index a786afbc37..f11fa5f092 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -87,17 +87,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeShippedRoot` | `true` | 在全部已配置根目录之前,前置本包随附的内置 preset 作为 `system` 根目录 | | `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 -### 可写根目录属于本包,随附根目录属于 app +### 随附根目录与可写根目录都属于本包 + +随附的 preset 就在本包内部、`lib/` 旁随行分发,正如每个 preset 自己的 skill 随其目录一起走。其根目录前置在全部已配置根目录**之前**,因此内置集合始终挂载并赢得重复 id——任何整体替换 roster 行 `config` 的补丁层都不会意外弄丢它,schema 默认值让该集合在整份 `config` 被替换后依然保留。这些组合依赖的是宿主的 agent-plane 服务,而不是某个特定表面:宿主缺少某个 preset 行注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。 `/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-filesystem`](../../skill/skill-filesystem/README.zh.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 -`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 +`includeShippedRoot: false` 去掉内置集合——适用于只提供自有 preset 的部署,或把 roster 当作纯机制使用的嵌入方。`includeUserRoot: false` 去掉推导出的可写根目录——适用于把 preset 限制在自有目录内的部署。钉住确切 roster 的测试两者都要关——否则将由本包的随附 preset 与这台机器真实的 `` 决定 roster 的内容。 随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 08c035c953..4bd33091c2 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -33,7 +33,8 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "presets" ], "license": "MIT", "peerDependencies": { diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/packages/preset/agent-presets/presets/code/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/code/agent.cordis.yml rename to packages/preset/agent-presets/presets/code/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/code/preset.yml b/packages/preset/agent-presets/presets/code/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/code/preset.yml rename to packages/preset/agent-presets/presets/code/preset.yml diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/cordis/agent.cordis.yml rename to packages/preset/agent-presets/presets/cordis/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/cordis/preset.yml b/packages/preset/agent-presets/presets/cordis/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/cordis/preset.yml rename to packages/preset/agent-presets/presets/cordis/preset.yml diff --git a/apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md similarity index 100% rename from apps/cli/config/agent-presets/cordis/skills/cordis-plugin-development/SKILL.md rename to packages/preset/agent-presets/presets/cordis/skills/cordis-plugin-development/SKILL.md diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md similarity index 100% rename from apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md rename to packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/packages/preset/agent-presets/presets/minimal/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/minimal/agent.cordis.yml rename to packages/preset/agent-presets/presets/minimal/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/packages/preset/agent-presets/presets/minimal/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/minimal/preset.yml rename to packages/preset/agent-presets/presets/minimal/preset.yml diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml similarity index 100% rename from apps/cli/config/agent-presets/standard/agent.cordis.yml rename to packages/preset/agent-presets/presets/standard/agent.cordis.yml diff --git a/apps/cli/config/agent-presets/standard/preset.yml b/packages/preset/agent-presets/presets/standard/preset.yml similarity index 100% rename from apps/cli/config/agent-presets/standard/preset.yml rename to packages/preset/agent-presets/presets/standard/preset.yml diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 8e3ed2020b..5f3de734ac 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -16,6 +16,7 @@ import { readdir, readFile, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { load } from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { expandHomePath } from '@deepseek-ai/dsh-home-paths' @@ -29,10 +30,9 @@ export const COMPOSITION_FILE = 'agent.cordis.yml' * Harness-home directory holding locally authored presets. * * This package owns the writable root the way `dsh-skill-filesystem` owns - * `/skills`. An app must assemble the SHIPPED root, whose path only - * the installed app can resolve; where a person's own presets go is the same - * place in every deployment that does not say otherwise, so a launcher that - * forgets to configure one still finds them. + * `/skills`: where a person's own presets go is the same place in + * every deployment that does not say otherwise, so a launcher that forgets to + * configure one still finds them. * * Package-internal on purpose: no consumer outside this package addresses the * directory by name, and a test that imported it could not catch this value @@ -40,6 +40,15 @@ export const COMPOSITION_FILE = 'agent.cordis.yml' */ export const USER_PRESET_DIR = '.agent-presets' +/** + * The shipped presets, bundled inside this package: the roster's built-in + * compositions travel with the machinery that mounts them, the way each + * preset's own skills travel inside its directory. Resolved relative to this + * module so both launch layouts work — `src/` under tsx and the bundled + * `lib/` sit one level below the package root. + */ +export const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../presets/', import.meta.url)) + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6a24a89d76..332c41a670 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -29,7 +29,7 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' -import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' +import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' @@ -50,7 +50,7 @@ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) -export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { COMPOSITION_FILE, discoverPresets, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts' export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' @@ -89,18 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeShippedRoot: z.boolean().default(true), includeUserRoot: z.boolean().default(true), }) as z /** - * The roots discovery and authoring actually scan: every configured root in + * The roots discovery and authoring actually scan: the package's shipped + * root unless `includeShippedRoot` is false, then every configured root in * order, then the harness-home user root unless `includeUserRoot` is false. * * Derived once, because a root set that changed between `list()` and the * `copy()` acting on its answer would author into a directory the caller - * never saw. Appending rather than prepending keeps an earlier configured - * root winning a duplicate id, so a shipped preset still shadows a - * locally authored directory that claimed its name. + * never saw. The shipped root comes FIRST and the user root LAST because an + * earlier root wins a duplicate id: a shipped preset shadows any directory + * that claimed its name, and a configured root still shadows a locally + * authored one. */ private readonly resolvedRoots: readonly PresetRoot[] @@ -130,9 +133,11 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx - this.resolvedRoots = config.includeUserRoot - ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] - : [...config.roots] + this.resolvedRoots = [ + ...config.includeShippedRoot ? [{ path: SHIPPED_PRESET_ROOT, trust: 'system' } satisfies PresetRoot] : [], + ...config.roots, + ...config.includeUserRoot ? [{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' } satisfies PresetRoot] : [], + ] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -338,10 +343,11 @@ export class AgentPresets extends Service { } /** - * The roots this roster scans, which is not `config.roots`: it is every - * configured root in order, then the harness-home user root unless - * `includeUserRoot` is false. Read this — not the config field — to answer - * whether a roster is composed at all, so one derivation decides it. + * The roots this roster scans, which is not `config.roots`: the package's + * shipped root unless `includeShippedRoot` is false, every configured root + * in order, then the harness-home user root unless `includeUserRoot` is + * false. Read this — not the config field — to answer whether a roster is + * composed at all, so one derivation decides it. */ get roots(): readonly PresetRoot[] { return this.resolvedRoots diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index 554348cdd6..bbb02c5623 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,9 +54,17 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Prepend this package's bundled shipped presets as a `system` root, before + * every configured root, so the shipped set always mounts and wins a + * duplicate id. The default survives a whole-`config` patch replacement; + * only an explicit `false` — a deployment supplying purely its own presets, + * or an embedder using the roster as bare machinery — drops the set. + */ + includeShippedRoot: boolean /** * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every - * configured root. False mounts a roster over `roots` alone. + * configured root. False mounts a roster without the derived writable root. */ includeUserRoot: boolean } diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 8086996111..2166229cf6 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,9 +52,11 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], - // Every roster in this file pins its own roots: the derived harness-home - // root would add the developer's real presets to what these assertions - // count, and `copy` would write into it. + // Every roster in this file pins its own roots: the package's shipped + // presets would shadow the fixture ids, and the derived harness-home root + // would add the developer's real presets to what these assertions count — + // and `copy` would write into it. + includeShippedRoot: false, includeUserRoot: false, }) }) @@ -203,6 +205,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeShippedRoot: false, includeUserRoot: false, }) @@ -224,6 +227,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeShippedRoot: false, includeUserRoot: false, }) @@ -246,6 +250,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeShippedRoot: false, includeUserRoot: false, }) diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index dda3644f55..02352afb8e 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -31,7 +31,7 @@ async function harness(roster: Partial = {}): Promise { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantRegistry) await ctx.plugin(AgentPresetsInvariant) return ctx diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 824308e009..ed93a7de53 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeShippedRoot: false, includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -534,7 +534,7 @@ describe('replacing a composition', () => { // exactly right there and the diagnostic must stay silent. Opting out is // what makes this rosterless — empty `roots` alone would still derive the // harness-home root, which is a roster like any other. - const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) + const rosterless = await harness({ default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -583,7 +583,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -623,7 +623,7 @@ describe('editing a composition file', () => { await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) - const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index e3af9e1ec1..1c549f7874 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeShippedRoot: false, includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts new file mode 100644 index 0000000000..30b974aae8 --- /dev/null +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -0,0 +1,90 @@ +/** + * The shipped presets are this package's own, not an assembly fact each app + * must patch in: a roster configured with nothing still supplies the built-in + * compositions, prepended so they always mount and win a duplicate id. + * `includeShippedRoot: false` is how a deployment supplying purely its own + * presets — or an embedder using the roster as bare machinery — opts out. + * + * `$DSH_HOME` is repointed per test for the same reason as the user-root + * suite: the derived writable root is resolved in the constructor. + */ + +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') + +let previousHome: string | undefined + +beforeEach(async () => { + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster with the shipped root left to the plugin's default. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [], + includeShippedRoot: true, + includeUserRoot: true, + ...config, + }) + return ctx +} + +describe('the shipped preset root', () => { + it('supplies the built-in presets from a bare roster, healthy and system-trusted', async () => { + const ctx = await roster({ includeUserRoot: false }) + + const listed = await ctx.agentPresets.list() + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(listed.every(preset => preset.broken === undefined)).toBe(true) + }) + + it('prepends the shipped root before configured roots and the derived user root', async () => { + const ctx = await roster({ roots: [{ path: SYSTEM_ROOT, trust: 'user' }] }) + + expect(ctx.agentPresets.roots.map(root => root.path)).toEqual([ + SHIPPED_PRESET_ROOT, + SYSTEM_ROOT, + expect.stringContaining('.agent-presets'), + ]) + expect(ctx.agentPresets.roots[0]).toEqual({ path: SHIPPED_PRESET_ROOT, trust: 'system' }) + // Prepended, so a configured directory claiming a shipped id is shadowed: + // the fixture root also carries `minimal`, and the roster serves the + // shipped one. + const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal') + expect(minimal?.path.startsWith(SHIPPED_PRESET_ROOT)).toBe(true) + }) + + it('mounts a roster without the shipped set when includeShippedRoot is false', async () => { + const ctx = await roster({ + includeShippedRoot: false, + includeUserRoot: false, + roots: [{ path: SYSTEM_ROOT, trust: 'system' }], + }) + + expect(ctx.agentPresets.roots).toEqual([{ path: SYSTEM_ROOT, trust: 'system' }]) + const minimal = (await ctx.agentPresets.list()).find(preset => preset.id === 'minimal') + expect(minimal?.path.startsWith(SYSTEM_ROOT)).toBe(true) + }) +}) diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index 4ecf42864b..c749db8a75 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -50,6 +50,8 @@ async function roster(config: Partial = {}): Promise { await ctx.plugin(AgentPresets, { default: 'standard', roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + // The package's shipped presets would shadow this file's fixture ids. + includeShippedRoot: false, includeUserRoot: true, ...config, }) diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index b4c5d5736e..706b81b128 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 195a8bb23e..49981d16c0 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -84,7 +84,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // Asserts the vendored-manifest table, which gains an upstream-name column. { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, // `cordis` is also an agent-preset id — the directory name under - // apps/cli/config/agent-presets/ — so in these files the bare name is + // packages/preset/agent-presets/presets/ — so in these files the bare name is // product data, not a package reference. Renaming it changed which preset // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, @@ -98,7 +98,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // The preset's own composition: its header comment and its system prompt name // the preset a model mounts, so the scoped name would send the model after an // id no roster reports. - { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, // The preset-roster loop names the `cordis` preset id, not a package. { file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] }, // GROUP_ORDER holds `packages//` directory names, not package names. @@ -159,8 +159,8 @@ const POSTCONDITIONS: readonly PostCondition[] = [ // The preset ids in this table are product data, not package names. { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, // The preset id the shipped composition documents to its own model. - { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, - { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, + { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, + { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 }, ] diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 3fa1babcb9..9a10b4f2db 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -147,7 +147,7 @@ function validatePresetPlaneSeparation(): string[] { } // The overlay's own inserts are host-plane too; its disables take them back out. const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id))) - for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) { + for (const file of globSync('packages/preset/agent-presets/presets/*/agent.cordis.yml', { cwd: root })) { for (const id of rowIds(file)) { if (!active.has(id)) continue problems.push( diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 09a1b044ab..6a395afe30 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -39,7 +39,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: tools name: cordis:group group: true @@ -68,7 +68,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: conditional name: '@scope/conditional' disabled: !!js process.env.DSH_DISABLE_CONDITIONAL === '1' @@ -86,7 +86,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: plugin name: '@scope/plugin' config: @@ -103,7 +103,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': '1.2.3' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': ` - id: plugin name: '@scope/plugin' `, @@ -126,7 +126,7 @@ describe('verifyRuntimeClosure', () => { expect(result.presetCount).toBe(0) expect(result.failures).toEqual([ - 'no agent presets matched apps/cli/config/agent-presets/*/agent.cordis.yml', + 'no agent presets matched packages/preset/agent-presets/presets/*/agent.cordis.yml', ]) }) @@ -134,7 +134,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, 'python/sdk-runtime/platforms.json': {}, - 'apps/cli/config/agent-presets/standard/agent.cordis.yml': '[]\n', + 'packages/preset/agent-presets/presets/standard/agent.cordis.yml': '[]\n', }) const result = await verifyRuntimeClosure(root) @@ -148,7 +148,7 @@ describe('verifyRuntimeClosure', () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, - 'apps/cli/config/agent-presets/minimal/agent.cordis.yml': '[]\n', + 'packages/preset/agent-presets/presets/minimal/agent.cordis.yml': '[]\n', }) workspace(root, '@scope/root', { peerDependencies: { '@scope/required': 'workspace:^', '@scope/optional': 'workspace:^' }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 927bae8db5..d0127fc68d 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -30,7 +30,7 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const AGENT_PRESET_GLOB = 'apps/cli/config/agent-presets/*/agent.cordis.yml' +const AGENT_PRESET_GLOB = 'packages/preset/agent-presets/presets/*/agent.cordis.yml' export interface RuntimeClosureResult { failures: string[] From d858832bbbd005e196d3821e67c2cd34a2c1a8dc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 21 Aug 2026 13:42:23 +0800 Subject: [PATCH 04/21] chore(constraints): register the preset-root files policy The files constraint tables gained per-package expectations on master while this branch changed two files lists: apps/cli no longer ships config/, and dsh-agent-presets ships presets/ (ordered where the expected-files derivation places extras). --- packages/preset/agent-presets/package.json | 4 ++-- scripts/check-workspace-constraints.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 4bd33091c2..8d42525d76 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -32,9 +32,9 @@ "files": [ "lib/index.js", "lib/invariant.js", + "presets", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "presets" + "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e87106ed14..c50ae50272 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -57,7 +57,7 @@ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|app const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { - '@deepseek-ai/dsh': ['lib/*.js', 'config'], + '@deepseek-ai/dsh': ['lib/*.js'], // The Web build emits sourcemaps for browser debugging; publishing them is // what the payload policy forbids, so the bundle ships without them. '@deepseek-ai/dsh-web-frontend': ['dist', '!dist/**/*.map'], @@ -151,6 +151,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], // The CPython side ships as source .py files, published as-is rather than built. '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], + // The shipped preset compositions travel inside the roster package. + '@deepseek-ai/dsh-agent-presets': ['presets'], // The Python runtime uses a distinct closed-resolution bin; the public CLI // keeps config-owned bare-package resolution through lib/bin.js. '@deepseek-ai/dsh-sdk-jsonrpc-demo': ['lib/packaged-bin.js'], From c365daa53ca4a67069f59a14cdf5e1a9ba18bbd7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 21 Aug 2026 13:42:24 +0800 Subject: [PATCH 05/21] chore(rescope): realign two manifest anchors, allowlist the preset-id spec Exposed by this branch touching rescope-vendor.ts, which runs the full rescope check: the knip-logger-console exact edit targeted the packages/util/home knip section that #2758 deleted (drop the edit), the zh vendoring-cookbook anchor predates the rescope.zh.md link localization (follow it), and the new shipped-root.spec.ts joins the files whose bare 'cordis' tokens are preset ids. --- scripts/rescope-vendor.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 49981d16c0..040e27a308 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -88,6 +88,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // product data, not a package reference. Renaming it changed which preset // the creator flow stages and which id the roster reports. { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, + { file: 'packages/preset/agent-presets/tests/shipped-root.spec.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/tests/apply.client.spec.ts', upstream: ['cordis'] }, { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', upstream: ['cordis'] }, @@ -196,23 +197,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [ errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, expect: 1, }, - { - // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it. - id: 'knip-logger-console', - file: 'knip.json', - find: ` "ignoreDependencies": [ - "@cordisjs/plugin-logger-console", - "@deepseek-ai/.+" - ] - }, - "packages/util/home": {`, - replace: ` "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/util/home": {`, - expect: 1, - }, { id: 'knip-bundle-base', file: 'knip.json', @@ -348,7 +332,7 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ id: 'vendoring-cookbook-name-invariant-zh', file: 'docs/cookbook/adding-a-vendored-package.zh.md', find: '保留上游的 `name`/`version`/`exports`/`type`', - replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`', + replace: '改写 `name` 的 scope([映射](../rescope.zh.md)),保留上游的 `version`/`exports`/`type`', expect: 1, }, { From d97e3983832d320b9a869db07c94bccaa34b6f87 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 20 Aug 2026 22:01:50 +0800 Subject: [PATCH 06/21] fix(jsonl): warn when repairing torn tails --- packages/session/session-persistence-jsonl/src/index.ts | 1 + packages/session/session-persistence-jsonl/tests/zstd.spec.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 5113746fec..f8c1a86bef 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -441,6 +441,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) + if (tornMarker !== undefined) this.ctx.logger.warn(`${this.name}: session "${meta.id}" recovered from a torn tail; incomplete tail bytes were discarded`) } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b9cced0087..01695e3488 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -539,6 +539,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('recover-torn', '/proj') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await ctx.sessionPersistence.create(header) await ctx.sessionPersistence.append(header.id, oneTurnLog()) const path = logPath(root, header.cwd, header.id, 'zstd') @@ -562,6 +563,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { expect(loaded.events.some(event => event.type === 'assistant/chunk' && event.seq === 8)).toBe(false) expect(loaded.events[8]?.type).toBe('step/end') expect(loaded.events[9]?.type).toBe('turn/end') + expect(warn).toHaveBeenCalledWith('session-persistence-jsonl: session "recover-torn" recovered from a torn tail; incomplete tail bytes were discarded') const repaired = await readFile(path) expect(repaired.subarray(0, committed.length)).toEqual(committed) From 05daf25e106ead8e798cda1b5ba4ffd222e5c357 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 10:17:11 +0800 Subject: [PATCH 07/21] test(llm): pin includeShippedRoot off in the inventory roster The spec landed on master before the roster gained the plugin-bundled shipped root; its empty-roots harness now opts out explicitly, matching every other exact-roster suite. --- .../plugin-package-inventory-deepseek/tests/inventory.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index aabbe7362f..d6662caa2f 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -44,7 +44,7 @@ async function harness(enabled?: boolean): Promise<{ ctx: Context; root: string; await ctx.plugin(Loader) ctx.loader.builtins.include = Include await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeShippedRoot: false, includeUserRoot: false }) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) const inventory = enabled === undefined ? ctx.plugin(PluginInventory) From 34a3097317a703e549fa44b1897567ee29049bbd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 10:29:18 +0800 Subject: [PATCH 08/21] fix(preview): point the config-tree declaration at the plugin-bundled presets The worker-preview pack landed on master declaring dsh.configTrees against apps/cli/config/agent-presets, which this branch moved into packages/preset/agent-presets/presets. The VFS mount and the worker-side roster patch keep their paths; only the source directory follows the move. --- apps/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index f572b2b80b..49f7fc84d1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,7 +19,7 @@ ], "dsh": { "configTrees": [ - { "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true } + { "mount": "config/agent-presets", "path": "../../packages/preset/agent-presets/presets", "scanRoster": true } ] }, "license": "MIT", From 8fe9af8db9baf02821804a08b83a6d62fb5f357b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:35:25 +0800 Subject: [PATCH 09/21] feat(webworker): support fs watches and confinement --- .../2026-08-20-webworker-node-face.i18n.yaml | 4 +- .../2026-08-20-webworker-node-face.md | 10 +- .../2026-08-20-webworker-node-face.zh.md | 10 +- ...webworker-vfs-watch-and-landlock.i18n.yaml | 6 + ...-08-23-webworker-vfs-watch-and-landlock.md | 80 +++ ...-23-webworker-vfs-watch-and-landlock.zh.md | 80 +++ THIRD_PARTY_NOTICES.md | 2 + apps/web/tests/preview-boot.e2e.ts | 91 +++- .../webworker-packer/README.i18n.yaml | 4 +- .../experimental/webworker-packer/README.md | 2 +- .../webworker-packer/README.zh.md | 2 +- .../webworker-packer/src/repository.ts | 8 +- .../tests/image-loadable.spec.ts | 49 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 8 +- .../webworker-runtime/README.zh.md | 8 +- .../webworker-runtime/package.json | 11 +- .../webworker-runtime/src/module-proxies.ts | 4 +- .../implemented/child_process.ts | 106 +++- .../builtin_modules/implemented/fs-watch.ts | 419 +++++++++++++++ .../node/builtin_modules/implemented/fs.ts | 345 ++++++++++-- .../implemented/fs/promises.ts | 2 +- .../builtin_modules/implemented/stream.ts | 82 +++ .../src/node/builtin_modules/mock/stream.ts | 38 -- .../webworker-runtime/src/node/builtins.ts | 6 +- .../src/node/external_packages/chokidar.ts | 68 --- .../node-addon-landlock-run.ts | 31 -- .../external_packages/replaced-externals.ts | 2 - .../webworker-runtime/src/shell/fs-access.ts | 6 +- .../src/shell/process/landlock.ts | 188 +++++++ .../src/shell/process/virtual-executables.ts | 61 +++ .../webworker-runtime/src/storage/active.ts | 8 +- .../webworker-runtime/src/storage/memory.ts | 192 +++++-- .../webworker-runtime/src/storage/types.ts | 98 ++++ .../tests/node/child-process.spec.ts | 185 +++++++ .../tests/node/chokidar.spec.ts | 210 ++++++++ .../tests/node/fs-watch-stream.spec.ts | 507 ++++++++++++++++++ .../webworker-runtime/tests/node/fs.spec.ts | 16 +- .../tests/node/node-stubs.spec.ts | 44 +- .../tests/node/sandbox-stack.spec.ts | 98 ++++ .../tests/storage/memory-vfs.spec.ts | 119 +++- pnpm-lock.yaml | 73 +++ 42 files changed, 2940 insertions(+), 347 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts delete mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts delete mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts delete mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts create mode 100644 packages/experimental/webworker-runtime/src/shell/process/landlock.ts create mode 100644 packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml index 4b5a98a8d5..47269a11c8 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md -2026-08-20-webworker-node-face.md: 08119cce96eff244f8e9ada3462ce5d35c1b538d -2026-08-20-webworker-node-face.zh.md: 573c0be055d066d2d6d0db11a2476ba528517727 +2026-08-20-webworker-node-face.md: 6e69af83354f1139a03d047a700e84e7e918a013 +2026-08-20-webworker-node-face.zh.md: 96a60e459372828273b3a4d1330a7b7eb8f2994f diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md index 08119cce96..6e69af8335 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md @@ -6,21 +6,21 @@ English | [中文](2026-08-20-webworker-node-face.zh.md) ## Problem -The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool, which mounted, advertised itself to the model, and then failed on every call while `node:child_process` was a structural stub. +The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool. A structural `node:child_process` stub would let that tool mount and advertise itself to the model while every call fails. ## Decision **Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly. -**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. The hunt that produced this also fixed the silence around it: cordis's logger verbosity counts UP, so an exporter that declares no level drops every warning — `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts. +**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning. -**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. The table is the machine's whole `/bin`; an absent name reports `command not found` (127). Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub. +**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. Ordinary commands resolve from that table; native-package protocols may contribute Worker-owned virtual executable wrappers through the [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md). A name in neither set reports `ENOENT` at direct spawn or `command not found` (127) inside shell source. Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub. ## Alternatives considered **Replacing `dsh-subprocess-local` or the bash executor.** The first would let the proxy table replace a workspace package against its own classification and invert the layering; the second trips `dsh-permission-presets`' boot-time `sandboxMode` validation and drops tested timeout/output behavior. -**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); WASM was removed from this deployment by decision and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused. +**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); this deployment excludes WASM and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused. **`SharedArrayBuffer` + `Atomics.wait` for a synchronous child filesystem.** Measured on the deployment target: without COOP/COEP headers `SharedArrayBuffer` is not defined, and GitHub Pages cannot set response headers. The asynchronous face is a superset; a SAB backend can slot under it later without touching a program. @@ -28,7 +28,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work ## Consequences -- Sandbox modes other than `danger-full-access` fail loud: `SandboxEnforcement` has no "nothing was enforced" value and a browser has no kernel, so `ctx.sandbox.confine` fails closed and the command never starts. Real enforcement at the VFS frame gate is a designed follow-up, not this note. +- `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world. - The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`. - Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts. - The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md index 573c0be055..96a60e4593 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md @@ -6,21 +6,21 @@ ## 问题 -worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层——在 `node:child_process` 还是结构桩的时期,工具照常挂载、向模型自我宣告,然后每次调用都失败。 +worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层。如果 `node:child_process` 只是结构桩,工具仍会照常挂载并向模型自我宣告,但每次调用都会失败。 ## 决定 **Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。 -**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。这场排查同时修掉了它周围的静默:cordis 日志器的详细度数值向上计数,未声明等级的 exporter 会丢掉所有 warning——`startWorkerHost` 在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter。 +**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。 -**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。命令表就是这台机器的全部 `/bin`;不存在的名字报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。 +**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。普通命令从该表解析;native 包协议可以通过 [watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)提供 Worker 自有的虚拟 executable wrapper。两处都没有的名字在直接 spawn 时报告 `ENOENT`,在 shell source 中则报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。 ## 曾考虑的替代方案 **整包替换 `dsh-subprocess-local` 或替换 bash 执行器。** 前者让代理表首次替换 workspace 包、违背其自身分类并倒置分层;后者撞上 `dsh-permission-presets` 对 `sandboxMode` 的 boot 期硬校验,并丢掉执行器已被测试钉住的超时/输出行为。 -**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);WASM 已被本部署的决定排除,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。 +**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);本部署排除 WASM,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。 **`SharedArrayBuffer` + `Atomics.wait` 给子进程同步文件系统。** 在部署目标实测:无 COOP/COEP 头时 `SharedArrayBuffer` 未定义,而 GitHub Pages 无法设置响应头。异步面是超集;SAB 后端将来可垫入其下而不动任何程序。 @@ -28,7 +28,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属 ## 后果 -- `danger-full-access` 之外的沙箱档 fail loud:`SandboxEnforcement` 没有「未执法」值、浏览器没有内核,`ctx.sandbox.confine` 落闭、命令零启动。在 VFS 帧闸口做真执法是设计中的后续,不属本条。 +- `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。 - Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。 - 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。 - 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml new file mode 100644 index 0000000000..34a24f6261 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md +2026-08-23-webworker-vfs-watch-and-landlock.md: 61254092e0f32e8e4291fd7c21489684110a1d15 +2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 32e1b36e0ef17e4574252693e246f9d7cd4d4712 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md new file mode 100644 index 0000000000..61254092e0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md @@ -0,0 +1,80 @@ +# Agent Note: Web Worker VFS watching and CLI-compatible confinement + +Status: implemented + +English | [中文](2026-08-23-webworker-vfs-watch-and-landlock.zh.md) + +## Problem + +The Web Worker preview boots the same Web profile and Agent presets as the Node host. Without a VFS change source, refusing `node:fs.watchFile` makes `skill-filesystem` return an incomplete observation and re-scan on every lookup, while an inert success leaves an existing root waiting forever for Chokidar's `ready`. Settings and credentials likewise need real external-edit events rather than a package-specific fake. + +The same composition mounts `sandbox-local`, whose Linux chain probes bwrap and then `@deepseek-ai/node-addon-landlock-run`. A Worker cannot execute either binary. Ending the selection there makes `workspace-write` and `read-only` unusable even though every shell filesystem operation already crosses a Host-side VFS call point. + +The filesystem compatibility boundary follows the [Worker Node face decision](2026-08-20-webworker-node-face.md): pure JavaScript watcher packages run unchanged over Node-compatible modules. Native or binary packages may keep their public JavaScript API and executable protocol while replacing the backend. An API that cannot preserve its caller-visible Node behavior remains explicitly unavailable; `node:vm` is outside this decision. + +## Decision + +### VFS mutation source and filesystem watchers + +`MemoryVfs` publishes committed `write`, `mkdir`, `remove`, and `chmod` mutations to any number of subscribers. Publication happens after state changes, failed operations publish nothing, image seeding stays silent, and one throwing subscriber cannot fail the filesystem operation or starve another subscriber. Rename is a source removal plus complete destination mkdir/write records; destination writes mark the directory entry as changed, so watchers report `rename` while a future durable sink receives the bytes needed to materialize the destination. Directory mtimes advance when their immediate entry set changes, so polling detects child creation and removal as Node does. + +The mutation record is shared with WebFS persistence rather than defining a second notification path. Writes carry their complete post-commit bytes and virtual permission bits, plus an append offset when only a tail changed. `MemoryVfs` accepts an optional asynchronous `VfsMutationSink`, sends the same records to that sink and live watcher subscribers, and exposes `flush()` through file-handle `sync()` and `datasync()`. Hydration supplies `{ mode, mtimeMs }` explicitly, so image permissions and durable timestamps cannot occupy the same positional argument. This change mounts no durable sink; it keeps the synchronous in-memory tree authoritative so an OPFS or user-directory mirror can hydrate before publication and write behind without changing `node:fs`. + +The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. + +`fs.watch` maps entry creation, removal, and rename destinations to `rename`, and maps content or mode changes to `change`. Non-recursive directory watches report immediate child names; recursive watches report paths relative to the watched directory. The VFS has no symlinks, so this implementation does not invent symlink events. + +### Streams and unchanged npm packages + +`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. + +Chokidar and readdirp are ordinary image dependencies, not module replacements. Their package code runs unchanged and imports the Worker implementations of `node:fs`, `node:fs/promises`, `node:stream`, `node:events`, `node:path`, and `node:os`. Chokidar therefore retains its own initial scan, `ready`, polling, atomic-write normalization, write-settle delay, shared watcher, and close behavior. + +### Landlock CLI over per-process VFS grants + +`@deepseek-ai/node-addon-landlock-run` is an ordinary image dependency, not a module replacement. Its unchanged JavaScript entry runs through the Worker implementations of `node:child_process`, `node:module`, `node:path`, and `node:url`, so the package remains the sole owner of `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT`, `launcherPath()`, `grantArgs()`, and `probe()`. The image may include the matching Linux optional package, but package resolution does not decide whether the Worker platform supplies Landlock: the entry package's deterministic fallback path reaches the same platform executable implementation when that optional package is absent. + +The process layer has a table of Worker platform executables identified by logical executable name rather than one package-manager path. Its `landlock-run` provider accepts a bare command or an absolute launcher path, parses the native package's unchanged CLI, validates every grant root, and delegates the inner argv to the existing shell process runner. `node:child_process` performs only generic executable lookup, output delivery, and settlement. The unchanged package's synchronous `probe()` therefore observes the provider through `spawnSync` and reports `full`. A usage error, missing grant root, or unknown inner executable prints one `landlock-run: ...` line, exits `125`, and never runs the inner command. The bwrap probe remains unavailable, so the unmodified `sandbox-local` Linux chain selects this Landlock backend. + +Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. + +The Worker's `full` verdict covers every file operation expressible through its shell command table and Host-served VFS protocol. It does not claim Linux kernel Landlock, arbitrary native executable support, or protection against a future shell program that bypasses `ShellFileSystem`. + +### Explicitly deferred behavior + +`node:vm`, `node:worker_threads`, `node:net`, `node:sqlite`, native PTY, Sharp, and ripgrep remain outside this change. The VFS remains POSIX-only, in-memory, and symlink-free. Browser Workers have no libuv-style ref-counted event loop, so watcher `persistent`, `ref()`, and `unref()` preserve the API and observable state but cannot decide Worker lifetime. + +## Alternatives considered + +**Disable watcher and sandbox rows in the Worker profile.** A smaller composition would stop testing the same Host tree and would hide package integration failures specific to preview deployment. + +**Make `watchFile` an inert success.** Missing roots would never advance, and an existing root would wait forever for Chokidar `ready`. + +**Notify watchers only from `node:fs`.** Shell process requests and any direct VFS writer would bypass the notification point. The commit owner, `MemoryVfs`, is the only complete source. + +**Keep a VFS-specific Chokidar replacement.** This duplicates directory scans, ready accounting, write settling, atomic replacement, shared watcher ownership, and teardown already maintained upstream. + +**Replace the Landlock entry package with a Worker module.** Reimplementing its exported constants, grant builder, launcher resolution, and probe would create a second copy of a package contract that already runs over the Worker Node compatibility layer. Only the platform executable implementation differs. + +**Recognize one exact launcher path.** Optional-dependency installation and the entry package's documented fallback produce different absolute paths for the same executable. Package-manager layout is not the identity of a platform capability, so executable dispatch uses the logical `landlock-run` name. + +**Add a Worker branch to `sandbox-local`.** This would copy policy-to-grant mapping into a business package. Interpreting the existing launcher protocol preserves the provider, consumer, configuration, diagnostics, and native package API. + +**Store one active policy on the global VFS.** Concurrent foreground, background, and escalated commands would overwrite one another's authority. Grants belong to one process handle and its filesystem adapter. + +## Verification + +- `fs-watch-stream.spec.ts` compares missing/create/change/remove `watchFile` transitions and file-stream lifecycle, chunking, range, backpressure, byte count, defaults, and abort identity with the running Node version. +- `chokidar.spec.ts` loads both lockfile-selected Chokidar and readdirp dependency pairs through the Worker transformer and module loader, then proves `ready`, callback watching, polling, missing-file creation, removal, and quiescent close over `MemoryVfs`. +- `image-loadable.spec.ts` packs and loads the real `@deepseek-ai/node-addon-landlock-run` JavaScript, proves it is absent from the replacement table, and runs its fallback `launcherPath()` and `probe()` through the Worker platform executable. `child-process.spec.ts` and `sandbox-stack.spec.ts` then prove the launcher failure code, malformed argv and grant failures, `/tmp` and `/dev/null`, rename denial, all three permission modes, and concurrent process-local grants through the production sandbox and subprocess packages. +- `preview-boot.e2e.ts` builds and boots the packed browser deployment, creates a Workspace and Session, advances missing skill roots into a live Chokidar watch, lists the catalog, and completes settings and credential writes without watcher warnings. + +## Consequences + +The preview now runs npm watcher consumers without source forks, and filesystem mutations observed from Host code or shell process Workers share one ordered commit source. A WebFS/OPFS integration remains an asynchronous mirror around this synchronous authority and consumes that same source; it does not add another Chokidar implementation or a competing mutation protocol. + +Worker `read-only` and `workspace-write` preserve the product's permission vocabulary and denial reporting without forking the Landlock npm package. Their security claim is narrower than native Landlock but complete inside the Worker execution world; any new filesystem message or shell program must continue through the guarded `ShellFileSystem`. Native-backed packages follow the same ownership rule: their JavaScript remains upstream, while the Worker platform replaces only the native artifact behind it. + +The worker bundle gains `readable-stream` and its small browser dependency closure. In return, stream state and backpressure remain maintained upstream instead of becoming local compatibility code. + +Watcher event timing is deterministic from VFS commits rather than inherited from an operating-system backend. This stays within Node's watcher contract, which does not guarantee native event coalescing, while tests pin every event distinction the current consumers require. diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md new file mode 100644 index 0000000000..32e1b36e0e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md @@ -0,0 +1,80 @@ +# Agent Note: Web Worker VFS 监听与 CLI 兼容 confinement + +Status: implemented + +[English](2026-08-23-webworker-vfs-watch-and-landlock.md) | 中文 + +## Problem + +Web Worker preview 启动与 Node host 相同的 Web profile 和 Agent preset。缺少 VFS 变更源时,拒绝 `node:fs.watchFile` 会让 `skill-filesystem` 返回不完整观测并在每次查询时重新扫描,而无事件的成功调用会让已有根永远等待 Chokidar 的 `ready`。Settings 和 credentials 同样需要真实的外部编辑事件,而不是包专用 fake。 + +同一组合挂载 `sandbox-local`,其 Linux 选择链依次探测 bwrap 和 `@deepseek-ai/node-addon-landlock-run`。Worker 无法执行这两个二进制文件。如果选择链到此结束,`workspace-write` 与 `read-only` 将不可用,尽管 shell 的每项文件系统操作已经经过 Host 侧 VFS 调用点。 + +文件系统兼容边界遵循 [Worker Node face 决策](2026-08-20-webworker-node-face.zh.md):纯 JavaScript watcher 包在 Node 兼容模块之上保持原样运行。Native 或 binary 包可以保持公开 JavaScript API 与可执行文件协议,同时替换执行后端。无法维持调用方可见 Node 行为的 API 继续明确标记为不可用;`node:vm` 不属于本决策范围。 + +## Decision + +### VFS mutation source 与文件 watcher + +`MemoryVfs` 向任意数量的订阅方发布已提交的 `write`、`mkdir`、`remove` 和 `chmod` mutation。状态改变后才发布,失败操作不发布,镜像 seed 保持无事件,一个抛错的订阅方也不能让文件系统操作失败或阻止其他订阅方。Rename 被表达为源路径删除与包含完整状态的目标 mkdir/write 记录;目标 write 会标记目录项已改变,因此 watcher 报告 `rename`,未来的 durable sink 同时拿到物化目标所需的字节。目录的直接条目集合改变时,其 mtime 会推进,因此 polling 能像 Node 一样发现子项创建和删除。 + +Mutation record 与 WebFS 持久化共用,而不建立第二条通知路径。Write 记录携带提交后的完整字节与虚拟权限位,并在只有尾部变化时携带 append offset。`MemoryVfs` 接受可选的异步 `VfsMutationSink`,把同一批记录交给 sink 与实时 watcher 订阅方,并通过文件句柄的 `sync()` 和 `datasync()` 暴露 `flush()`。水合通过显式的 `{ mode, mtimeMs }` 传入元数据,因此镜像权限与持久化时间戳不会占用同一个位置参数。本次变更不挂载 durable sink;同步内存树继续作为权威,因此 OPFS 或用户目录 mirror 可以先水合、再异步写回,而无需改变 `node:fs`。 + +`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。 + +`fs.watch` 把条目创建、删除和 rename 目标映射为 `rename`,把内容或 mode 变化映射为 `change`。非递归目录 watcher 报告直接子项名,递归 watcher 报告相对被监听目录的路径。VFS 没有符号链接,因此该实现不会制造符号链接事件。 + +### Stream 与未修改的 NPM 包 + +`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。 + +Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replacement。它们的包代码保持原样,并导入 Worker 实现的 `node:fs`、`node:fs/promises`、`node:stream`、`node:events`、`node:path` 与 `node:os`。因此,初次扫描、`ready`、polling、原子写归一化、写入稳定等待、共享 watcher 与关闭行为仍由 Chokidar 自己负责。 + +### 基于逐进程 VFS 授权的 Landlock CLI + +`@deepseek-ai/node-addon-landlock-run` 是普通镜像依赖,不是模块 replacement。其未经修改的 JavaScript 入口通过 Worker 实现的 `node:child_process`、`node:module`、`node:path` 与 `node:url` 运行,因此该包仍是 `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`、`launcherPath()`、`grantArgs()` 和 `probe()` 的唯一所有者。镜像可以包含匹配的 Linux optional package,但包解析不决定 Worker 平台是否提供 Landlock;缺少该 optional package 时,入口包产生的确定性 fallback 路径仍到达同一个平台可执行文件实现。 + +进程层持有按逻辑可执行文件名识别的 Worker 平台可执行文件表,而不依赖某一个包管理器路径。其 `landlock-run` provider 接受裸命令或绝对 launcher 路径,解析 native 包未经修改的 CLI、校验每个授权根,并把内部 argv 交给既有 shell 进程 runner。`node:child_process` 只负责通用的可执行文件查找、输出投递与结束处理。因此,原包的同步 `probe()` 会通过 `spawnSync` 观察到该 provider 并报告 `full`。用法错误、缺失的授权根或未知内部可执行文件只输出一行 `landlock-run: ...`,以 `125` 退出,并且绝不运行内部命令。bwrap 仍探测为不可用,因此未修改的 `sandbox-local` Linux 选择链会选中该 Landlock 后端。 + +每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 + +Worker 的 `full` 结论覆盖 shell 命令表和 Host 服务 VFS 协议能够表达的全部文件操作。它不表示 Linux 内核 Landlock、不支持任意 native 可执行文件,也无法约束未来绕过 `ShellFileSystem` 的 shell 程序。 + +### 明确延后的行为 + +`node:vm`、`node:worker_threads`、`node:net`、`node:sqlite`、native PTY、Sharp 和 ripgrep 不属于本次变更。VFS 仍然只支持 POSIX、内存存储且没有符号链接。Browser Worker 没有 libuv 风格的引用计数事件循环,因此 watcher 的 `persistent`、`ref()` 和 `unref()` 保留 API 与可观察状态,但不能决定 Worker 生存期。 + +## Alternatives considered + +**在 Worker profile 中禁用 watcher 与 sandbox 配置项。** 缩减组合后将不再测试相同的 Host tree,还会隐藏 preview 部署特有的包集成故障。 + +**让 `watchFile` 成为无事件的成功调用。** 缺失根永远无法推进,已有根则会永久等待 Chokidar `ready`。 + +**只从 `node:fs` 通知 watcher。** Shell 进程请求以及直接写 VFS 的实现可以绕过通知点。只有提交状态的 `MemoryVfs` 才是完整真源。 + +**保留 VFS 专用的 Chokidar replacement。** 这会重复实现上游已经维护的目录扫描、ready 计数、写入稳定等待、原子替换、共享 watcher 所有权和 teardown。 + +**用 Worker 模块替换 Landlock 入口包。** 重新实现其导出常量、授权参数构造、launcher 解析和 probe,会为一个已经能在 Worker Node 兼容层上运行的包约定建立第二份副本。只有平台可执行文件实现需要不同。 + +**只识别一个精确 launcher 路径。** Optional dependency 的安装状态与入口包已有的 fallback 会为同一个可执行文件产生不同的绝对路径。包管理器布局不是平台能力的身份,因此可执行文件分发使用逻辑名称 `landlock-run`。 + +**在 `sandbox-local` 中增加 Worker 分支。** 这会把策略到授权的映射复制到业务包中。解释现有 launcher 协议可以保持 provider、consumer、配置、诊断和 native 包 API 不变。 + +**在全局 VFS 上保存一个当前策略。** 并发前台、后台和升权命令会覆盖彼此的权限。授权必须归属于单个进程句柄及其文件系统适配器。 + +## Verification + +- `fs-watch-stream.spec.ts` 对照当前 Node 版本验证缺失、创建、修改、删除的 `watchFile` 状态转换,以及文件流生命周期、分片、范围、backpressure、字节计数、默认值和 abort 身份。 +- `chokidar.spec.ts` 通过 Worker transformer 与模块 loader 加载 lockfile 选定的两组 Chokidar 和 readdirp 依赖,并在 `MemoryVfs` 上验证 `ready`、callback watcher、polling、缺失文件创建、删除和完全停稳的关闭。 +- `image-loadable.spec.ts` 打包并加载真实的 `@deepseek-ai/node-addon-landlock-run` JavaScript,验证它不在 replacement 表中,并让其 fallback `launcherPath()` 与 `probe()` 经过 Worker 平台可执行文件。`child-process.spec.ts` 与 `sandbox-stack.spec.ts` 随后通过生产 sandbox 和 subprocess 包验证 launcher 失败码、错误 argv 与授权失败、`/tmp` 与 `/dev/null`、rename 拒绝、三种权限模式和逐进程并发授权。 +- `preview-boot.e2e.ts` 构建并启动打包后的浏览器部署,创建 Workspace 与 Session,把缺失的 skill 根逐级推进到可用的 Chokidar watch,读取 catalog,并在没有 watcher 警告的情况下完成 settings 与 credential 写入。 + +## Consequences + +Preview 现在可以在不 fork 源码的情况下运行 NPM watcher 消费方;Host 代码与 shell 进程 Worker 产生的文件系统 mutation 共享同一个有序提交源。WebFS/OPFS 集成仍是围绕该同步权威的异步 mirror,并消费同一个变更源;它不会增加另一份 Chokidar 实现或互相竞争的 mutation 协议。 + +Worker `read-only` 与 `workspace-write` 在不 fork Landlock NPM 包的情况下保留产品权限词汇和拒绝报告。其安全结论比 native Landlock 更窄,但完整覆盖 Worker 执行世界;任何新的文件系统消息或 shell 程序都必须继续经过受 guard 保护的 `ShellFileSystem`。Native-backed 包遵循同一所有权规则:其 JavaScript 保持上游实现,Worker 平台只替换背后的 native artifact。 + +Worker bundle 增加 `readable-stream` 及其少量浏览器依赖。相应地,stream 状态和 backpressure 继续由上游维护,不成为本地兼容代码。 + +Watcher 事件时序由 VFS 提交确定,而不是继承操作系统后端。Node watcher 约定本身不保证 native 事件合并方式,因此该实现仍符合约定;测试固定当前消费方依赖的每一种事件区别。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b82665e1c9..f2637fe699 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -87,6 +87,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | +| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | @@ -140,6 +141,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index b2d6add83a..1b8bc85f32 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -8,7 +8,9 @@ * Two milestones prove that happened — the host's `tree active` boot line, * whose lowering contract must be the one this checkout's packer emits, and the * workspace hero, which paints only after the client tree comes up over the - * tunnel. + * tunnel. The same page then creates a Workspace and Session, lists skills, + * and writes through the settings and credentials providers, exercising the + * upstream Chokidar instances over the Worker filesystem implementation. * * The site is served the way a static host serves it: bytes from `dist/` with * no rewrite rules, so a missing file is a 404 rather than the index page. @@ -212,6 +214,7 @@ it('boots the packed worker deployment to an interactive page', async () => { async function bootPreview(origin: string, browser: Browser): Promise { const page = await newEnglishPage(browser) const pageErrors: Error[] = [] + const consoleErrors: string[] = [] page.on('pageerror', (error) => { pageErrors.push(error) }) // Registered before navigation: the worker reports its tree long before the // tunnel serves the client, so a listener added later would miss the line. @@ -219,6 +222,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { page.on('console', (message) => { const text = message.text() if (text.includes(TREE_ACTIVE)) reported(text) + if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text) }) }) try { @@ -232,7 +236,92 @@ async function bootPreview(origin: string, browser: Browser): Promise { // surface, so it appears only once the startup chain completed over the // tunnel. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + const continueButton = page.getByRole('button', { name: 'Continue' }) + if (await continueButton.isVisible()) await continueButton.click() + await page.getByRole('button', { name: 'Configure later' }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Edit path' }).click() + const pathInput = dialog.getByRole('textbox', { name: 'Edit path' }) + await pathInput.fill('/dsh/workspace') + await pathInput.press('Enter') + await dialog.getByRole('button', { name: 'Open', exact: true }).click() + await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') + .waitFor({ timeout: 30_000 }) + + const exercised = await page.evaluate(async () => { + type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } + interface PreviewApi { + host: { createDirectory(payload: { path: string; name: string }): Promise> } + skills: { list(payload: { sessionId: string }): Promise> } + settings: { + describe(payload: object): Promise }>> + update(payload: { ns: string; patch: object; expectedRevision: number }): Promise> + } + credentials: { + set(payload: { ref: string; value: string }): Promise> + unset(payload: { ref: string }): Promise> + describe(payload: { refs: string[] }): Promise + }>> + } + } + interface PreviewTransport { + fetch(input: string, init: RequestInit): Promise + createApiClient(): PreviewApi + } + const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: PreviewTransport }).__DSH_TRANSPORT__ + if (transport === undefined) throw new Error('preview transport is absent after boot') + const response = await transport.fetch('/api/session/list', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'preview-session-list', method: 'session/list', + payload: { args: { _request: {} } }, + }), + }) + const sessions = await response.json() as Result<{ items: Array<{ sessionId: string }> }> + if (!sessions.result.ok) throw new Error(`session/list failed: ${sessions.result.error.message}`) + const sessionId = sessions.result.value.items[0]?.sessionId + if (sessionId === undefined) throw new Error('workspace adoption created no Session') + + const api = transport.createApiClient() + const skills = await api.skills.list({ sessionId }) + if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) + const createDirectory = async (path: string, name: string): Promise => { + const created = await api.host.createDirectory({ path, name }) + if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`) + await new Promise((resolve) => { setTimeout(resolve, 250) }) + const refreshed = await api.skills.list({ sessionId }) + if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) + } + await createDirectory('/dsh/workspace', '.agents') + await createDirectory('/dsh/workspace/.agents', 'skills') + await createDirectory('/dsh/workspace/.agents/skills', 'placeholder') + const settings = await api.settings.describe({}) + if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`) + const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell') + if (shell === undefined) throw new Error('settings.describe omitted the shell namespace') + const updated = await api.settings.update({ ns: 'shell', patch: { timeoutMs: 61_000 }, expectedRevision: shell.revision }) + if (!updated.result.ok) throw new Error(`settings.update failed: ${updated.result.error.message}`) + const stored = await api.credentials.set({ ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' }) + if (!stored.result.ok) throw new Error(`credentials.set failed: ${stored.result.error.message}`) + const credentials = await api.credentials.describe({ refs: ['PREVIEW_TEST_SECRET'] }) + if (!credentials.result.ok) throw new Error(`credentials.describe failed: ${credentials.result.error.message}`) + const removed = await api.credentials.unset({ ref: 'PREVIEW_TEST_SECRET' }) + if (!removed.result.ok) throw new Error(`credentials.unset failed: ${removed.result.error.message}`) + await new Promise((resolve) => { setTimeout(resolve, 250) }) + return { + skillCount: skills.result.value.skills.length, + credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured, + } + }) + expect(exercised.skillCount).toBeGreaterThanOrEqual(0) + expect(exercised.credentialConfigured).toBe(true) expect(pageErrors.map(error => error.message)).toEqual([]) + expect(consoleErrors.filter(line => + /watchFile|failed to watch|node-addon-landlock-run\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([]) } catch (error) { await saveFailureShot(page, 'preview-boot') throw pageErrors.length === 0 diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml index cab7fecde4..040b58adda 100644 --- a/packages/experimental/webworker-packer/README.i18n.yaml +++ b/packages/experimental/webworker-packer/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md -README.md: eb6c4c60106ebb7f6bb123116a749f152dea79a0 -README.zh.md: defa06c048125d627002c445aca491b4a33c4026 +README.md: a04f7579c8b3ddc7e94d2f8ed21251ed3efae302 +README.zh.md: 7876212ffb6ded5c45659502306758d8dd316f67 diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md index eb6c4c6010..a04f7579c8 100644 --- a/packages/experimental/webworker-packer/README.md +++ b/packages/experimental/webworker-packer/README.md @@ -10,7 +10,7 @@ The pack is a three-layer standard stack: 2. **Publish view** — each workspace package contributes the slice npm would publish (`files` through picomatch) minus the rule tables in `src/rules.ts` (no sources, no workspace `dist/`; external packages keep their trees minus the same exclude globs). 3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time. -`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. +`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. ## Model Experience diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md index defa06c048..7876212ffb 100644 --- a/packages/experimental/webworker-packer/README.zh.md +++ b/packages/experimental/webworker-packer/README.zh.md @@ -10,7 +10,7 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后 2. **发布视图**——每个 workspace 包贡献 npm 会发布的切片(`files` 走 picomatch),再减去 `src/rules.ts` 的规则表(无源码、无 workspace `dist/`;外部包保留整棵减同一套 exclude glob)。 3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。 -`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 +`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 ## 模型体验 diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts index 38ec64bd1d..6ae2e59bee 100644 --- a/packages/experimental/webworker-packer/src/repository.ts +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -16,11 +16,11 @@ import type { ConfigTree, PackResult } from './pack.ts' /** * Repository directories scanned for workspace and vendored packages. The - * image only ever materializes runtime packages, which all live here; - * examples, python, and native are never on a roster's dependency chain (the - * native addon is a replaced external). + * image only ever materializes runtime packages, which live here. The Landlock + * package family contributes its unchanged JavaScript entry from `native/`; + * examples and python never occur on a roster's dependency chain. */ -const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'apps'] +const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'native/landlock-run/packages', 'apps'] /** Composition entry point package: the `dsh` CLI, run from source. */ const CLI_PACKAGE = 'apps/cli' diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 688b21c279..e75b4f523b 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -21,7 +21,9 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts' -import { WorkerModuleLoader } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' +import { + setActiveModuleLoader, WorkerModuleLoader, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' import { indexWorkspacePackages } from '../src/repository.ts' @@ -31,6 +33,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) /** A leaf workspace package: real build output, no dependencies to drag in. */ const SUBJECT = '@deepseek-ai/dsh-timeout' +const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' const workspaces = indexWorkspacePackages(repoRoot) @@ -53,6 +56,15 @@ const packed = (): ReturnType => memo ??= packVfsImage({ entries: [], }) +let landlockMemo: ReturnType | undefined +const packedLandlock = (): ReturnType => landlockMemo ??= packVfsImage({ + config: `- id: subject\n name: '${LANDLOCK}'\n`, + profile: 'landlock-package-check', + workspaces, + resolveFrom: repoRoot, + entries: [], +}) + /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ let archiveMemo: Uint8Array | undefined const archive = async (): Promise => @@ -138,6 +150,41 @@ const archive = async (): Promise => expect(loader.usage().modules).toBeGreaterThan(0) }) + it('runs the unchanged Landlock entry package over the Worker platform executable', async () => { + const result = packedLandlock() + expect(workspaces.has(LANDLOCK)).toBe(true) + expect(result.packages.has(LANDLOCK)).toBe(true) + expect(result.missing).toEqual([]) + expect(Object.hasOwn(result.files, `node_modules/${LANDLOCK}/lib/index.js`)).toBe(true) + expect(createNodeBuiltins()[LANDLOCK]).toBeUndefined() + + const vfs = loadVfsImage(await inflateImage(result.image, 'the packed Landlock package'), DEFAULT_ROOT) + const loader = new WorkerModuleLoader({ + vfs, + root: DEFAULT_ROOT, + staticModules: createNodeBuiltins(), + staticModulePrefixes: REPLACED_PREFIXES, + }) + setActiveModuleLoader(loader) + const landlock = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(LANDLOCK) as { + LAUNCHER_BIN: string + LAUNCHER_FAILURE_EXIT: number + launcherPath(): string + grantArgs(grants: { readOnly?: readonly string[]; readWrite?: readonly string[] }): string[] + probe(): string + } + + expect(landlock.LAUNCHER_BIN).toBe('landlock-run') + expect(landlock.LAUNCHER_FAILURE_EXIT).toBe(125) + expect(landlock.grantArgs({ readOnly: ['/'], readWrite: ['/tmp'] })).toEqual([ + '--ro', '/', '--rw', '/tmp', + ]) + expect(landlock.launcherPath()).toBe( + `${DEFAULT_ROOT}/node_modules/${LANDLOCK}/node_modules/${LANDLOCK}-${process.platform}-${process.arch}/bin/landlock-run`, + ) + expect(landlock.probe()).toBe('full') + }) + it('refuses a body the packer did not lower, naming the image', async () => { // The case above only proves the packed bytes are wrappable. This is the // other half: the loader has no transform to fall back on, so an entry the diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 0dced963dc..6dc2eb15e7 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: b82c65b981be6a9405ae72e3a24a42c68b52696e -README.zh.md: 97160641a38095026d5103f2e423846bfb41d5a6 +README.md: 88d4213b5eb2f82cc41ad5059d9473d4a8abf53b +README.zh.md: 6f4fd2612d5daa28831890ff64171ad780274958 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index b82c65b981..88d4213b5e 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -7,8 +7,8 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and replaced externals. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). -- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). The grammar is `@yarnpkg/parsers`' `parseShell`; this package owns the evaluator (pipelines, `&&`/`||`, subshells, redirections, expansion, globs) and the command table, which is the only `/bin` that exists — a name it does not hold reports `command not found`, and `execSync`/`fork` still refuse, because they need a real process. +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium. @@ -24,9 +24,9 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`. -- **The skill catalog is never cached in the worker** — `skill-filesystem` watches its roots through `node:fs.watchFile`, which this package refuses, so every discovery pass returns an incomplete observation and re-scans. Discovery itself stays correct; the cost is a re-scan on every pass. - **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here. -- **The bash tool runs only under `danger-full-access`**: a browser has no kernel to confine a command with, so `ctx.sandbox.confine` fails loud in every other permission preset and the command never starts. The mode is the deployment's own user-facing switch, not a worker-specific composition. +- **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop. +- **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation. - **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there. - **The shell is not bash**: no loops, functions, `case`, job control, or process substitution — the grammar stops at pipelines, `&&`/`||`, subshells, groups, redirections, and expansion. `&` runs its command to completion in place, `sed` accepts only substitution scripts, patterns are JavaScript regular expressions, and the command table holds coreutils only (no `git`, no network tools). - **A shell process has no synchronous filesystem**: it reads and writes the host's VFS by message, because blocking on a reply would need `SharedArrayBuffer`, which requires a cross-origin isolation GitHub Pages cannot grant. Directory-walking commands therefore cost one round trip per entry, and two concurrent commands can interleave their writes. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 97160641a3..6f4fd2612d 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -7,8 +7,8 @@ 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS/隧道/浏览器原语,浏览器做不到的走结构化 stub(调用即 console 报错并抛出),外部包整体替换。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 -- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。语法来自 `@yarnpkg/parsers` 的 `parseShell`;求值器(管道、`&&`/`||`、子 shell、重定向、展开、glob)与命令表由本包自持,而命令表就是这里唯一存在的 `/bin`——表里没有的名字报 `command not found`,`execSync`/`fork` 依然拒绝,因为它们需要真进程。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。 @@ -24,9 +24,9 @@ ## Known Limitations and Deferred Work - **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。 -- **worker 里的技能目录从不缓存**——`skill-filesystem` 用 `node:fs.watchFile` 监听各个根,而本包拒绝该调用,于是每轮发现都返回不完整观测并重新扫描。发现本身仍然正确,代价是每轮都要重扫。 - **`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。 -- **bash 工具只在 `danger-full-access` 下可用**:浏览器没有内核可以约束命令,因此在其余权限档位下 `ctx.sandbox.confine` 会响亮失败、命令根本不会启动。该档位是部署本身的用户面开关,不是 worker 特有的组合差异。 +- **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。 +- **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。 - **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。 - **这个 shell 不是 bash**:没有循环、函数、`case`、作业控制或进程替换——语法止步于管道、`&&`/`||`、子 shell、group、重定向与展开。`&` 会就地把命令跑完,`sed` 只接受替换脚本,模式是 JavaScript 正则,命令表只有 coreutils(没有 `git`,没有网络工具)。 - **shell 进程没有同步文件面**:它靠消息读写宿主的 VFS,因为阻塞等待回帧需要 `SharedArrayBuffer`,而那要求 GitHub Pages 给不了的跨源隔离。因此目录遍历类命令每个条目一次往返,并发的两条命令写入可以交错。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index c083f13fc1..422bd4f5ec 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -35,7 +35,8 @@ "@yarnpkg/parsers": "^3.1.0", "acorn": "^8.17.0", "buffer": "^6.0.3", - "picomatch": "^4.0.4" + "picomatch": "^4.0.4", + "readable-stream": "^4.7.0" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -49,12 +50,18 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@types/picomatch": "^3.0.2" + "@deepseek-ai/node-addon-landlock-run": "workspace:^", + "@types/picomatch": "^3.0.2", + "@types/readable-stream": "^4.0.24", + "chokidar": "^5.0.0" }, "files": [ "lib/index.js", diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index e30128d293..4e95da027c 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -56,7 +56,7 @@ export const MODULE_PROXIES: Record = { 'node:child_process': './node/builtin_modules/implemented/child_process.ts', // Structural mocks: every symbol exists, every call throws. 'node:net': './node/builtin_modules/mock/net.ts', - 'node:stream': './node/builtin_modules/mock/stream.ts', + 'node:stream': './node/builtin_modules/implemented/stream.ts', 'node:vm': './node/builtin_modules/mock/vm.ts', 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', @@ -66,10 +66,8 @@ export const MODULE_PROXIES: Record = { 'node-pty': './node/external_packages/node-pty.ts', '@vscode/ripgrep': './node/external_packages/ripgrep.ts', '@earendil-works/pi-ai': './node/external_packages/pi-ai.ts', - '@deepseek-ai/node-addon-landlock-run': './node/external_packages/node-addon-landlock-run.ts', // Constructible fakes whose methods are never reached. 'ws': './node/external_packages/ws.ts', - 'chokidar': './node/external_packages/chokidar.ts', } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts index 2c863ce6bb..e70d1323d1 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts @@ -5,9 +5,10 @@ * `spawn` starts the argv as a shell process (`src/shell/process/`) — its own * Web Worker, off this thread — and reports it through the `ChildProcess` * surface the subprocess service consumes: pipes, `exit`/`close`, pid, and - * signals, with `SIGKILL` terminating the worker for real. The command table - * is the only `/bin` that exists, so a name it does not hold fails with - * `ENOENT`, exactly as a missing binary does on a real host. + * signals, with `SIGKILL` terminating the worker for real. Worker-owned + * executable wrappers resolve before the shell's command table; anything in + * neither set fails with `ENOENT`, exactly as a missing binary does on a real + * host. * * What stays impossible is what needs a real process: synchronous execution * (`execSync`, and `spawnSync` for a known program) and `fork`. @@ -19,7 +20,11 @@ import { EventEmitter } from './events.ts' import { notImplementedFail } from '../../notImplementedFail.ts' import { registerProcess, releaseProcess, signalProcess } from '../../process-table.ts' import { startProcess } from '../../../shell/process/host.ts' +import { hostFileSystem } from '../../../shell/fs-access.ts' +import { virtualExecutable } from '../../../shell/process/virtual-executables.ts' +import type { VirtualExecutableExit } from '../../../shell/process/virtual-executables.ts' import { standardPrograms } from '../../../shell/programs/index.ts' +import type { ShellFileSystem } from '../../../shell/types.ts' import { DSH_ROOT } from '../../../storage/paths.ts' const MODULE = 'node:child_process' @@ -219,9 +224,6 @@ export function spawn( const entry = registerProcess() const child = new WorkerChildProcess(entry.pid, stdio) - const script = shellScriptOf(argv) - const known = script !== undefined || standardPrograms().has(program) - const emit = (stream: 'stdout' | 'stderr', text: string): void => { if (text === '') return const pipe = stream === 'stdout' ? child.stdout : child.stderr @@ -236,7 +238,10 @@ export function spawn( } } + let settled = false const settle = (exitCode: number): void => { + if (settled) return + settled = true releaseProcess(entry.pid) // A signalled command reports no exit code, which is what makes the // subprocess service classify it as killed rather than finished. @@ -248,31 +253,68 @@ export function spawn( child.emit('exit', child.exitCode, signal) child.emit('close', child.exitCode, signal) } + const failSpawn = (error: Error): void => { + if (settled) return + settled = true + releaseProcess(entry.pid) + child.emit('error', error) + } // The command starts on a microtask, so a caller that attaches listeners and // writes standard input right after `spawn()` — the subprocess service does // exactly that — is never racing the first output. queueMicrotask(() => { - if (!known) { - releaseProcess(entry.pid) - child.emit('error', spawnEnoent(program)) - return - } - entry.process = startProcess({ - script, - argv, - cwd: options.cwd ?? DSH_ROOT, - env: environmentOf(options.env), - stdin: child.stdin?.contents() ?? '', - onOutput: emit, - onExit: settle, + void (async () => { + const cwd = options.cwd ?? DSH_ROOT + let commandArgv: readonly string[] = argv + let filesystem: ShellFileSystem | undefined + let missingExecutable: VirtualExecutableExit | undefined + const executable = virtualExecutable(program) + if (executable !== undefined) { + const prepared = await executable.prepare(args, { cwd, filesystem: hostFileSystem() }) + if (prepared.kind === 'exit') { + emit('stdout', prepared.stdout) + emit('stderr', prepared.stderr) + settle(prepared.exitCode) + return + } + commandArgv = prepared.argv + filesystem = prepared.filesystem + missingExecutable = prepared.missingExecutable + } + + const command = commandArgv[0] as string + const script = shellScriptOf(commandArgv) + const known = script !== undefined || standardPrograms().has(command) + if (!known) { + if (missingExecutable !== undefined) { + emit('stdout', missingExecutable.stdout) + emit('stderr', missingExecutable.stderr) + settle(missingExecutable.exitCode) + } else { + failSpawn(spawnEnoent(program)) + } + return + } + entry.process = startProcess({ + script, + argv: commandArgv, + cwd, + env: environmentOf(options.env), + stdin: child.stdin?.contents() ?? '', + onOutput: emit, + onExit: settle, + ...filesystem === undefined ? {} : { fs: filesystem }, + }) + // A signal that arrived while the process was still starting has to reach + // it now; the table recorded it but had nothing to deliver it to. + if (entry.signal !== undefined) { + if (entry.signal === 'SIGKILL') entry.process.destroy() + else entry.process.interrupt() + } + })().catch((error: unknown) => { + failSpawn(error instanceof Error ? error : new Error(String(error))) }) - // A signal that arrived while the process was still starting has to reach - // it now; the table recorded it but had nothing to deliver it to. - if (entry.signal !== undefined) { - if (entry.signal === 'SIGKILL') entry.process.destroy() - else entry.process.interrupt() - } }) return child @@ -298,10 +340,22 @@ export interface WorkerSpawnSyncResult { * answers in the same shape: absent programs report `ENOENT`, and a program * this shell *does* have reports that only the asynchronous path can run it. * @param program - the program name. + * @param args - arguments passed to the virtual launcher probe. * @returns the Node-shaped synchronous result carrying the failure. */ -export function spawnSync(program: string): WorkerSpawnSyncResult { +export function spawnSync(program: string, args: readonly string[] = []): WorkerSpawnSyncResult { const empty = Buffer.alloc(0) + const executable = virtualExecutable(program) + if (executable !== undefined) { + const result = executable.runSync(args) + if (result.kind === 'asynchronous') { + const error = new Error(`${MODULE}.spawnSync cannot run ${program} in the worker host: commands run asynchronously`) + return { pid: -1, status: null, signal: null, stdout: empty, stderr: empty, output: [null, empty, empty], error } + } + const stdout = Buffer.from(result.stdout) + const stderr = Buffer.from(result.stderr) + return { pid: -1, status: result.exitCode, signal: null, stdout, stderr, output: [null, stdout, stderr] } + } const error = standardPrograms().has(program) ? new Error(`${MODULE}.spawnSync cannot run ${program} in the worker host: commands run asynchronously`) : spawnEnoent(program) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts new file mode 100644 index 0000000000..396ae3c22e --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -0,0 +1,419 @@ +/** Node filesystem watching over the active in-memory VFS. */ +import { Buffer } from 'buffer' +import { EventEmitter } from './events.ts' +import { captureAsyncContext, runWithAsyncContext } from './async_hooks.ts' +import { basename, relative, resolve, sep } from './path.ts' +import { requireActiveVfs } from '../../../storage/active.ts' +import type { VfsBigIntStats, VfsMutation, VfsStats } from '../../../storage/types.ts' + +type PathArg = string | URL | Uint8Array +type WatchListener = (eventType: 'rename' | 'change', filename: string | Buffer | null) => void +type WatchStats = VfsStats | VfsBigIntStats +type StatListener = (current: WatchStats, previous: WatchStats) => void + +/** Options shared by the callback and promise watch faces. */ +export interface WatchOptions { + persistent?: boolean + recursive?: boolean + encoding?: BufferEncoding | 'buffer' + signal?: AbortSignal +} +/** Poll-style watch options. */ +export interface WatchFileOptions { + persistent?: boolean + interval?: number + bigint?: boolean +} + +const asPath = (path: PathArg): string => { + if (typeof path === 'string') return resolve(path) + if (path instanceof URL) return resolve(decodeURIComponent(path.pathname)) + return resolve(new TextDecoder().decode(path)) +} + +const missingStats = (bigint: boolean): WatchStats => ({ + size: bigint ? 0n : 0, + ino: bigint ? 0n : 0, + mtimeMs: bigint ? 0n : 0, + ctimeMs: bigint ? 0n : 0, + atimeMs: bigint ? 0n : 0, + birthtimeMs: bigint ? 0n : 0, + mtime: new Date(0), + mode: bigint ? 0n : 0, + ...bigint ? { + dev: 0n, + nlink: 0n, + mtimeNs: 0n, + ctimeNs: 0n, + atimeNs: 0n, + birthtimeNs: 0n, + ctime: new Date(0), + atime: new Date(0), + birthtime: new Date(0), + } : {}, + isFile: () => false, + isDirectory: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + isBlockDevice: () => false, + isCharacterDevice: () => false, +}) as WatchStats + +const statOrMissing = (path: string, bigint: boolean): WatchStats => { + try { + return requireActiveVfs().statSync(path, { bigint }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return missingStats(bigint) + throw error + } +} + +const statsChanged = (left: WatchStats, right: WatchStats): boolean => + left.size !== right.size + || left.mtimeMs !== right.mtimeMs + || left.mode !== right.mode + || left.ino !== right.ino + || left.isFile() !== right.isFile() + || left.isDirectory() !== right.isDirectory() + +const contains = (parent: string, child: string): boolean => + parent === '/' || child === parent || child.startsWith(`${parent}${sep}`) + +const overlaps = (left: string, right: string): boolean => contains(left, right) || contains(right, left) + +const abortError = (reason?: unknown): Error & { code: string; cause?: unknown } => { + const error = new Error('The operation was aborted') as Error & { code: string } + error.name = 'AbortError' + error.code = 'ABORT_ERR' + if (reason !== undefined) error.cause = reason + return error +} + +/** `fs.FSWatcher` over VFS mutations. */ +export class FSWatcher extends EventEmitter { + private readonly disposeMutation: () => void + private readonly signal: AbortSignal | undefined + private readonly onAbort: (() => void) | undefined + private closed = false + private referenced: boolean + + constructor( + private readonly target: string, + private readonly directory: boolean, + private readonly options: WatchOptions, + listener?: WatchListener, + ) { + super() + this.referenced = options.persistent ?? true + const context = captureAsyncContext() + if (listener !== undefined) this.on('change', listener as (...args: unknown[]) => void) + this.disposeMutation = requireActiveVfs().subscribe((mutation) => { + if (!this.matches(mutation)) return + const eventType = mutation.kind === 'write' && !mutation.entryChanged || mutation.kind === 'chmod' + ? 'change' + : 'rename' + const filename = this.filename(mutation.path) + queueMicrotask(() => { + if (this.closed) return + runWithAsyncContext(context, () => { this.emit('change', eventType, filename) }) + }) + }) + this.signal = options.signal + this.onAbort = options.signal === undefined ? undefined : () => { this.close() } + if (options.signal?.aborted === true) { + this.disposeMutation() + this.closed = true + throw abortError(options.signal.reason) + } + options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) + } + + private matches(mutation: VfsMutation): boolean { + if (mutation.path === this.target) return true + if (mutation.kind === 'remove' && contains(mutation.path, this.target)) return true + if (!this.directory || !contains(this.target, mutation.path)) return false + if (this.options.recursive === true) return true + const child = relative(this.target, mutation.path) + return child !== '' && !child.startsWith('..') && !child.includes(sep) + } + + private filename(path: string): string | Buffer { + const relativePath = relative(this.target, path) + const value = this.directory && contains(this.target, path) + ? this.options.recursive === true ? relativePath : relativePath.split(sep)[0] ?? '' + : basename(this.target) + return this.options.encoding === 'buffer' ? Buffer.from(value) : value + } + + /** Stop observing and publish `close` once. */ + close(): void { + if (this.closed) return + this.closed = true + this.disposeMutation() + if (this.onAbort !== undefined) this.signal?.removeEventListener('abort', this.onAbort) + queueMicrotask(() => { this.emit('close') }) + } + + /** + * Mark this watcher as process-liveness-bearing. + * @returns This watcher. + */ + ref(): this { + this.referenced = true + return this + } + + /** + * Clear the process-liveness flag; dedicated Workers have no ref-counted event loop. + * @returns This watcher. + */ + unref(): this { + this.referenced = false + return this + } + + /** + * Read the retained process-liveness flag. + * @returns Whether this watcher is marked as keeping its owner alive. + */ + hasRef(): boolean { + return this.referenced + } +} + +/** + * Watch one path through the active VFS. + * @param path - File or directory path. + * @param optionsOrListener - Watch options, encoding, or the change listener. + * @param maybeListener - Change listener when the second argument carries options. + * @returns The closeable watcher. + */ +export function watch( + path: PathArg, + optionsOrListener?: WatchOptions | BufferEncoding | 'buffer' | WatchListener, + maybeListener?: WatchListener, +): FSWatcher { + const options: WatchOptions = typeof optionsOrListener === 'object' + ? optionsOrListener + : typeof optionsOrListener === 'string' ? { encoding: optionsOrListener } : {} + const listener = typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener + const target = asPath(path) + const stats = requireActiveVfs().statSync(target) + return new FSWatcher(target, stats.isDirectory(), options, listener) +} + +/** `fs.StatWatcher` returned from `watchFile`. */ +export class StatWatcher extends EventEmitter { + private readonly disposeMutation: () => void + private timer: ReturnType | undefined + private previous: WatchStats + private stopped = false + private referenced: boolean + private readonly context: ReturnType + private readonly interval: number + private readonly bigint: boolean + + constructor(readonly path: string, options: WatchFileOptions) { + super() + this.referenced = options.persistent ?? true + this.interval = options.interval ?? 5007 + this.bigint = options.bigint ?? false + this.previous = statOrMissing(path, this.bigint) + this.context = captureAsyncContext() + this.disposeMutation = requireActiveVfs().subscribe((mutation) => { + if (overlaps(path, mutation.path)) this.schedule() + }) + if (!this.previous.isFile() && !this.previous.isDirectory()) this.schedule(true) + } + + private schedule(initialMissing = false): void { + if (this.stopped || this.timer !== undefined) return + this.timer = setTimeout(() => { + this.timer = undefined + if (this.stopped) return + const current = statOrMissing(this.path, this.bigint) + const previous = this.previous + this.previous = current + if (initialMissing || statsChanged(current, previous)) { + runWithAsyncContext(this.context, () => { this.emit('change', current, previous) }) + } + }, this.interval) + if (!this.referenced) timerUnref(this.timer) + } + + /** Stop polling and release the VFS subscription. */ + stop(): void { + if (this.stopped) return + this.stopped = true + this.disposeMutation() + if (this.timer !== undefined) clearTimeout(this.timer) + this.timer = undefined + this.emit('stop') + } + + /** Alias used by callers treating the watcher as a closeable handle. */ + close(): void { + this.stop() + } + + /** + * Mark this watcher as process-liveness-bearing. + * @returns This watcher. + */ + ref(): this { + this.referenced = true + if (this.timer !== undefined) timerRef(this.timer) + return this + } + + /** + * Mark this watcher as not keeping its owner alive. + * @returns This watcher. + */ + unref(): this { + this.referenced = false + if (this.timer !== undefined) timerUnref(this.timer) + return this + } + + /** + * Read the retained process-liveness flag. + * @returns Whether this watcher is marked as keeping its owner alive. + */ + hasRef(): boolean { + return this.referenced + } + +} + +type RefTimer = { ref?: () => unknown; unref?: () => unknown } + +/** Browser timers are numeric; Node timers expose optional liveness methods. */ +const timerRef = (timer: ReturnType): void => { + ;(timer as unknown as RefTimer).ref?.() +} + +/** Browser timers are numeric; Node timers expose optional liveness methods. */ +const timerUnref = (timer: ReturnType): void => { + ;(timer as unknown as RefTimer).unref?.() +} + +const statWatchers = new Map() + +/** + * Register a stat-poll watcher for one path. + * @param path - File or directory path, including a currently missing path. + * @param optionsOrListener - Polling options or the change listener. + * @param maybeListener - Change listener when the second argument carries options. + * @returns The path's shared stat watcher. + */ +export function watchFile( + path: PathArg, + optionsOrListener: WatchFileOptions | StatListener, + maybeListener?: StatListener, +): StatWatcher { + const options = typeof optionsOrListener === 'function' ? {} : optionsOrListener + const listener = typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener + if (listener === undefined) throw new TypeError('The "listener" argument must be of type function') + const target = asPath(path) + let watcher = statWatchers.get(target) + if (watcher === undefined) { + watcher = new StatWatcher(target, options) + statWatchers.set(target, watcher) + watcher.once('stop', () => { statWatchers.delete(target) }) + } + watcher.on('change', listener as (...args: unknown[]) => void) + return watcher +} + +/** + * Remove one listener or every listener for a path. + * @param path - Watched path. + * @param listener - Specific registration to remove; omission removes all registrations. + */ +export function unwatchFile(path: PathArg, listener?: StatListener): void { + const target = asPath(path) + const watcher = statWatchers.get(target) + if (watcher === undefined) return + if (listener === undefined) watcher.removeAllListeners('change') + else watcher.removeListener('change', listener as (...args: unknown[]) => void) + if (watcher.listenerCount('change') === 0) watcher.stop() +} + +/** + * Create the promise-based watch iterator over the callback watcher. + * @param path - File or directory path. + * @param options - Watch options and cancellation signal. + * @returns An iterator of change records that closes its watcher on return or failure. + */ +export function watchAsync( + path: PathArg, + options: WatchOptions = {}, +): AsyncIterableIterator<{ eventType: 'rename' | 'change'; filename: string | Buffer | null }> { + type WatchEvent = { eventType: 'rename' | 'change'; filename: string | Buffer | null } + type Waiting = { + resolve(result: IteratorResult): void + reject(reason: unknown): void + } + const queued: WatchEvent[] = [] + const waiting: Waiting[] = [] + let watcher: FSWatcher | undefined + let failure: Error | undefined + let closed = false + + const settleFailure = (reason: unknown): void => { + if (failure !== undefined || closed) return + const error = reason instanceof Error ? reason : new Error(String(reason)) + failure = error + watcher?.close() + for (const pending of waiting.splice(0)) pending.reject(error) + } + const onAbort = (): void => { settleFailure(abortError(options.signal?.reason)) } + const start = (): void => { + if (watcher !== undefined || closed || failure !== undefined) return + try { + watcher = watch(path, options, (eventType, filename) => { + const event = { eventType, filename } + const pending = waiting.shift() + if (pending === undefined) queued.push(event) + else pending.resolve({ done: false, value: event }) + }) + watcher.on('error', settleFailure) + options.signal?.addEventListener('abort', onAbort, { once: true }) + } catch (error) { + settleFailure(error) + } + } + const close = (): void => { + if (closed) return + closed = true + queued.length = 0 + options.signal?.removeEventListener('abort', onAbort) + watcher?.close() + for (const pending of waiting.splice(0)) pending.resolve({ done: true, value: undefined }) + } + + return { + [Symbol.asyncIterator]() { + return this + }, + next(): Promise> { + start() + if (failure !== undefined) return Promise.reject(failure) + const event = queued.shift() + if (event !== undefined) return Promise.resolve({ done: false, value: event }) + if (closed) return Promise.resolve({ done: true, value: undefined }) + return new Promise>((resolve, reject) => { waiting.push({ resolve, reject }) }) + }, + return(): Promise> { + close() + return Promise.resolve({ done: true, value: undefined }) + }, + throw(reason?: unknown): Promise> { + close() + // AsyncIterator.throw forwards the caller's exact reason, including non-Error values. + return Promise.reject(reason) + }, + } +} diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 848d04436b..0aea178db4 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -2,19 +2,20 @@ * `node:fs` bridge over the worker's in-memory VFS. `MemoryVfs` owns paths, * bytes, the directory tree, and Node's error codes; this module adds only what * is Node-API-shaped and not VFS business: Buffer results, `Dirent` objects, - * file descriptors, `mkdtemp`, access checks, inert watches, and the promise face. + * file descriptors, `mkdtemp`, access checks, watchers, streams, and the promise face. */ import { requireActiveVfs } from '../../../storage/active.ts' -import type { MemoryVfs } from '../../../storage/memory.ts' -import type { VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts' +import type { Vfs, VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts' import { Buffer } from 'buffer' +import { Readable, Writable } from './stream.ts' import { dirname } from './path.ts' +import { + FSWatcher, StatWatcher, unwatchFile, watch, watchAsync, watchFile, +} from './fs-watch.ts' -const vfs = (): MemoryVfs => requireActiveVfs() +const vfs = (): Vfs => requireActiveVfs() -const notImplemented = (method: string, subject: string): never => { - throw new Error(`web-preview: node:fs.${method} is not implemented in the worker host (${subject})`) -} +export { FSWatcher, StatWatcher, unwatchFile, watch, watchFile } type PathArg = string | URL | Uint8Array @@ -150,6 +151,29 @@ export function statSync(path: PathArg, options?: VfsStatOptions): VfsStats | Vf return vfs().statSync(asPath(path), options) } +/** + * Read stats through Node's callback form. + * @param path - Path to stat. + * @param optionsOrCallback - Stat options or the completion callback. + * @param maybeCallback - Completion callback when options are present. + */ +export function stat( + path: PathArg, + optionsOrCallback: VfsStatOptions | ((error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void), + maybeCallback?: (error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void, +): void { + const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback + const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback + if (callback === undefined) throw new TypeError('The "callback" argument must be of type function') + queueMicrotask(() => { + try { + callback(null, statSync(path, options)) + } catch (error) { + callback(error as NodeJS.ErrnoException) + } + }) +} + /** * Change an entry's permission bits; stat reads back exactly what was set. * @param path - the path. @@ -169,6 +193,20 @@ export function lstatSync(path: PathArg, options?: VfsStatOptions): VfsStats | V return statSync(path, options) } +/** + * Read link stats through Node's callback form; this symlink-free VFS delegates to stat. + * @param path - Path to stat. + * @param optionsOrCallback - Stat options or the completion callback. + * @param maybeCallback - Completion callback when options are present. + */ +export function lstat( + path: PathArg, + optionsOrCallback: VfsStatOptions | ((error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void), + maybeCallback?: (error: NodeJS.ErrnoException | null, stats?: VfsStats | VfsBigIntStats) => void, +): void { + stat(path, optionsOrCallback, maybeCallback) +} + /** * Canonical path (normalization only: the image is symlink-free). * @param path - the path. @@ -265,9 +303,10 @@ let nextFd = 3 * @param path - file path. * @param flags - Node flag string: 'r', 'w', 'a', with optional '+' and the * exclusive 'x' (create-only) modifier. + * @param mode - creation permission bits. * @returns the descriptor. */ -export function openSync(path: PathArg, flags = 'r'): number { +export function openSync(path: PathArg, flags = 'r', mode?: number): number { const target = asPath(path) const exists = vfs().existsSync(target) if (flags.includes('x') && exists) { @@ -277,7 +316,9 @@ export function openSync(path: PathArg, flags = 'r'): number { throw error } if (flags.startsWith('r')) vfs().realpathSync(target) - else if (flags.startsWith('w') || !exists) vfs().writeFileSync(target, new Uint8Array(0)) + else if (flags.startsWith('w') || !exists) { + vfs().writeFileSync(target, new Uint8Array(0), mode === undefined ? undefined : { mode }) + } const fd = nextFd++ openFiles.set(fd, { path: target, position: 0, append: flags.startsWith('a') }) return fd @@ -356,8 +397,8 @@ export function linkSync(from: PathArg, to: PathArg): void { /** * Open file handle (`fs.FileHandle` subset): the atomic-write and durability - * pair the storage backends use. `sync`/`datasync` are no-ops — an in-memory - * filesystem has nothing to flush, and a worker reload loses it either way. + * pair the storage backends use. `sync`/`datasync` settle the active VFS's + * optional write-behind sink. */ export interface FileHandle { readonly fd: number @@ -377,13 +418,14 @@ export interface FileHandle { * helpers do before an fsync. * @param path - file or directory path. * @param flags - Node flag string. + * @param mode - creation permission bits. * @returns the handle. */ -export function openHandleSync(path: PathArg, flags = 'r'): FileHandle { +export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileHandle { const target = asPath(path) const directory = vfs().existsSync(target) && vfs().statSync(target).isDirectory() const append = flags.startsWith('a') - const fd = directory ? -1 : openSync(target, flags) + const fd = directory ? -1 : openSync(target, flags, mode) return { fd, readFile: async (options?: EncodingOption) => readFileSync(target, options), @@ -403,56 +445,251 @@ export function openHandleSync(path: PathArg, flags = 'r'): FileHandle { truncate: async (length = 0) => { writeFileSync(target, bytesOf(target).subarray(0, length)) }, - sync: async () => { /* memory-backed: nothing to flush */ }, - datasync: async () => { /* memory-backed: nothing to flush */ }, + sync: async () => { await vfs().flush() }, + datasync: async () => { await vfs().flush() }, close: async () => { if (fd !== -1) closeSync(fd) }, } } -/** - * Watch registration refuses loudly, and NOT because watching is hard. - * - * An inert watcher would not serve this caller. `skill-filesystem` does not - * merely register a listener — `openStableWatcher` opens a watcher and then - * loops until two consecutive mode probes agree, so a watcher that reports - * success and never fires leaves `observeRoots()` awaiting forever: the skill - * catalog RPC never answers and the worker's single thread stops serving `/api` - * for the rest of the session. A refusal instead fails that path fast, which the - * provider already handles by returning an incomplete observation. - * - * So the family split is about what the CALLER does with the capability, not - * about the capability: a listener registration tolerates absence, a watcher - * whose progress is awaited does not. - * @param path - the path a caller wanted watched, named in the refusal. - * @returns Never — it throws naming the unavailable member. - */ -export function watchFile(path: PathArg): never { - return notImplemented('watchFile', asPath(path)) +/** Options supported by the VFS-backed read stream. */ +export interface ReadStreamOptions { + flags?: string + encoding?: BufferEncoding | null + autoClose?: boolean + emitClose?: boolean + start?: number + end?: number + highWaterMark?: number + signal?: AbortSignal } -/** Watch removal; teardown paths call it unconditionally, and nothing was watched. */ -export function unwatchFile(): void { - // No watch was ever established. +/** Options supported by the VFS-backed write stream. */ +export interface WriteStreamOptions { + flags?: string + encoding?: BufferEncoding | null + mode?: number + autoClose?: boolean + emitClose?: boolean + start?: number + highWaterMark?: number + signal?: AbortSignal +} + +const aborted = (reason?: unknown): Error => { + const error = new Error('The operation was aborted', { cause: reason }) as Error & { code: string } + error.name = 'AbortError' + error.code = 'ABORT_ERR' + return error +} + +/** Read stream over one VFS file. */ +export class ReadStream extends Readable { + /** Resolved path opened by this stream. */ + readonly path: string + /** Open descriptor, or null before open and after close. */ + fd: number | null = null + /** Whether the descriptor is still waiting to open. */ + pending = true + /** Bytes delivered by this stream. */ + bytesRead = 0 + private readonly start: number + private readonly end: number + private readonly flags: string + private readonly signal: AbortSignal | undefined + private readonly onAbort: (() => void) | undefined + private position: number + + constructor(path: PathArg, options: ReadStreamOptions = {}) { + super({ + autoDestroy: options.autoClose ?? true, + emitClose: options.emitClose ?? true, + highWaterMark: options.highWaterMark ?? 64 * 1024, + }) + this.path = asPath(path) + this.start = options.start ?? 0 + this.end = options.end ?? Number.POSITIVE_INFINITY + this.flags = options.flags ?? 'r' + this.position = this.start + this.signal = options.signal + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + if (options.encoding !== undefined && options.encoding !== null) this.setEncoding(options.encoding) + options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) + } + + override _construct(callback: (error?: Error | null) => void): void { + if (this.start < 0 || this.end < this.start) { + callback(new RangeError('The value of "start" is out of range')) + return + } + if (this.signal?.aborted === true) { + callback(aborted(this.signal.reason)) + return + } + try { + this.fd = openSync(this.path, this.flags) + this.pending = false + this.emit('open', this.fd) + this.emit('ready') + callback() + } catch (error) { + callback(error as Error) + } + } + + override _read(size: number): void { + if (this.fd === null) return + const remaining = this.end === Number.POSITIVE_INFINITY ? size : Math.min(size, this.end - this.position + 1) + if (remaining <= 0) { + this.push(null) + return + } + const buffer = Buffer.allocUnsafe(remaining) + let count: number + try { + count = readSync(this.fd, buffer, 0, remaining, this.position) + } catch (error) { + this.destroy(error as Error) + return + } + if (count === 0) { + this.push(null) + return + } + this.position += count + this.bytesRead += count + this.push(buffer.subarray(0, count)) + } + + override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { + this.signal?.removeEventListener('abort', this.onAbort as () => void) + if (this.fd !== null) closeSync(this.fd) + this.fd = null + this.pending = false + callback(error) + } + + /** + * Close the stream and release its descriptor. + * @param callback - Optional completion callback after `close`. + */ + close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { + if (callback !== undefined) this.once('close', () => { callback(null) }) + this.destroy() + } +} + +/** Writable stream committing chunks through the VFS file-descriptor face. */ +export class WriteStream extends Writable { + /** Resolved path opened by this stream. */ + readonly path: string + /** Open descriptor, or null before open and after close. */ + fd: number | null = null + /** Whether the descriptor is still waiting to open. */ + pending = true + /** Bytes committed by this stream. */ + bytesWritten = 0 + private readonly flags: string + private readonly mode: number | undefined + private readonly start: number | undefined + private readonly signal: AbortSignal | undefined + private readonly onAbort: (() => void) | undefined + + constructor(path: PathArg, options: WriteStreamOptions = {}) { + super({ + autoDestroy: options.autoClose ?? true, + decodeStrings: true, + defaultEncoding: options.encoding ?? 'utf8', + emitClose: options.emitClose ?? true, + highWaterMark: options.highWaterMark ?? 64 * 1024, + }) + this.path = asPath(path) + this.flags = options.flags ?? 'w' + this.mode = options.mode + this.start = options.start + this.signal = options.signal + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) + } + + override _construct(callback: (error?: Error | null) => void): void { + if (this.start !== undefined && this.start < 0) { + callback(new RangeError('The value of "start" is out of range')) + return + } + if (this.signal?.aborted === true) { + callback(aborted(this.signal.reason)) + return + } + try { + this.fd = openSync(this.path, this.flags, this.mode) + if (this.start !== undefined) fileOf(this.fd, 'write').position = this.start + this.pending = false + this.emit('open', this.fd) + this.emit('ready') + callback() + } catch (error) { + callback(error as Error) + } + } + + override _write( + chunk: string | Uint8Array, + encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + try { + if (this.fd === null) throw new Error('EBADF: bad file descriptor, write') + const data = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk + this.bytesWritten += writeSync(this.fd, data) + callback() + } catch (error) { + callback(error as Error) + } + } + + override _destroy(error: Error | null, callback: (error: Error | null) => void): void { + this.signal?.removeEventListener('abort', this.onAbort as () => void) + closeDescriptor(this.fd) + this.fd = null + this.pending = false + callback(error) + } + + /** + * Close the stream and release its descriptor. + * @param callback - Optional completion callback after `close`. + */ + close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { + if (callback !== undefined) this.once('close', () => { callback(null) }) + this.destroy() + } +} + +/** Close a stream-owned descriptor when it has opened successfully. */ +function closeDescriptor(fd: number | null): void { + if (fd !== null) closeSync(fd) } /** - * Streaming read is unavailable: node:stream has no implementation here. - * @param path - the path a caller wanted streamed, named in the refusal. - * @returns Never — it throws naming the unavailable member. + * Create a Node-compatible readable file stream over the VFS. + * @param path - File path. + * @param options - Encoding, range, open, buffer, and abort options. + * @returns The readable file stream. */ -export function createReadStream(path: PathArg): never { - return notImplemented('createReadStream', asPath(path)) +export function createReadStream(path: PathArg, options?: ReadStreamOptions | BufferEncoding): ReadStream { + return new ReadStream(path, typeof options === 'string' ? { encoding: options } : options) } /** - * Streaming write counterpart of {@link createReadStream}. - * @param path - the path a caller wanted streamed, named in the refusal. - * @returns Never — it throws naming the unavailable member. + * Create a Node-compatible writable file stream over the VFS. + * @param path - File path. + * @param options - Encoding, open, buffer, and abort options. + * @returns The writable file stream. */ -export function createWriteStream(path: PathArg): never { - return notImplemented('createWriteStream', asPath(path)) +export function createWriteStream(path: PathArg, options?: WriteStreamOptions | BufferEncoding): WriteStream { + return new WriteStream(path, typeof options === 'string' ? { encoding: options } : options) } /** Open directory handle (`fs.Dir` subset): iteration plus the close pair. */ @@ -538,11 +775,12 @@ export const promises = { // The VFS has no inodes, so a hard link is a byte copy: the caller's contract // is only that both names read the same content until one is removed. link: async (from: PathArg, to: PathArg): Promise => { linkSync(from, to) }, - open: async (path: PathArg, flags?: string): Promise => openHandleSync(path, flags), + open: async (path: PathArg, flags?: string, mode?: number): Promise => openHandleSync(path, flags, mode), opendir: async (path: PathArg): Promise => opendirSync(path), truncate: async (path: PathArg, length = 0): Promise => { writeFileSync(path, bytesOf(asPath(path)).subarray(0, length)) }, + watch: watchAsync, constants, } satisfies Partial> @@ -559,10 +797,11 @@ export const __esModule = true * the subsets the host tree reads. */ type OwnSignature = - | 'constants' | 'promises' | 'Dirent' + | 'constants' | 'promises' | 'Dirent' | 'FSWatcher' | 'StatWatcher' | 'ReadStream' | 'WriteStream' | 'readFileSync' | 'writeFileSync' | 'appendFileSync' | 'statSync' | 'lstatSync' | 'realpathSync' | 'readdirSync' | 'mkdirSync' | 'mkdtempSync' | 'rmSync' | 'opendirSync' - | 'openSync' | 'readSync' | 'writeSync' + | 'openSync' | 'readSync' | 'writeSync' | 'stat' | 'lstat' | 'watch' | 'watchFile' | 'unwatchFile' + | 'createReadStream' | 'createWriteStream' /** * The `node:fs` declarations this module stands in for. Every other member is @@ -574,10 +813,10 @@ type NodeFace = Partial> /** CommonJS default export: the members `require()` hands a caller of this module. */ export default { - constants, promises, Dirent, - readFileSync, writeFileSync, appendFileSync, existsSync, statSync, lstatSync, realpathSync, chmodSync, + constants, promises, Dirent, FSWatcher, StatWatcher, ReadStream, WriteStream, + readFileSync, writeFileSync, appendFileSync, existsSync, statSync, stat, lstatSync, lstat, realpathSync, chmodSync, readdirSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, renameSync, accessSync, opendirSync, openHandleSync, linkSync, - openSync, readSync, writeSync, closeSync, watchFile, unwatchFile, + openSync, readSync, writeSync, closeSync, watch, watchFile, unwatchFile, createReadStream, createWriteStream, } satisfies NodeFace diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts index a49ca4a22f..c83ca26b6d 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts @@ -9,7 +9,7 @@ import { Dirent, promises } from '../fs.ts' /** The promise members of the VFS bridge, as `node:fs/promises` names them. */ export const { readFile, writeFile, appendFile, mkdir, mkdtemp, readdir, stat, lstat, realpath, rm, unlink, - rename, access, chmod, cp, link, open, opendir, truncate, constants, + rename, access, chmod, cp, link, open, opendir, truncate, watch, constants, } = promises export { Dirent } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts new file mode 100644 index 0000000000..757961a42a --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts @@ -0,0 +1,82 @@ +/** + * `node:stream` compatibility backed by readable-stream's browser build. + * + * readable-stream is the userland copy of Node's stream implementation. The + * worker owns only platform adapters such as VFS file streams; stream state, + * backpressure, async iteration, abort handling, and event ordering stay in + * that maintained implementation. + */ +import Stream from 'readable-stream' + +type StreamRuntime = typeof import('node:stream') & { + compose(...streams: unknown[]): unknown + destroy(stream: unknown, error?: Error): void + isDisturbed(stream: unknown): boolean +} + +type StreamStatics = typeof import('node:stream').Stream & { + getDefaultHighWaterMark(objectMode: boolean): number + isDestroyed(stream: unknown): boolean | null + isWritable(stream: unknown): boolean | null + setDefaultHighWaterMark(objectMode: boolean, value: number): void +} + +const nodeStream = Stream as unknown as StreamRuntime + +const { + Duplex, PassThrough, Readable, Stream: StreamBase, Transform, Writable, + addAbortSignal, compose, destroy, finished, isDisturbed, isErrored, isReadable, pipeline, promises, +} = nodeStream +const streamStatics = StreamBase as unknown as StreamStatics +const { + getDefaultHighWaterMark, isDestroyed, isWritable, setDefaultHighWaterMark, +} = streamStatics + +// readable-stream tracks Node 18's 16 KiB byte default; this repository runs +// Node 22+, whose generic and file streams use 64 KiB. +if (getDefaultHighWaterMark(false) !== 64 * 1024) setDefaultHighWaterMark(false, 64 * 1024) + +/** + * Test whether a value is an ArrayBuffer view. + * @param value - Candidate value. + * @returns Whether the value is a typed-array or DataView instance. + */ +const _isArrayBufferView = (value: unknown): value is ArrayBufferView => ArrayBuffer.isView(value) + +/** Default-import namespace carrying Node's stream class and static helpers. */ +const streamDefault = Object.assign(Stream, { + _isArrayBufferView, + getDefaultHighWaterMark, + isDestroyed, + isWritable, + setDefaultHighWaterMark, +}) + +export { + Duplex, + PassThrough, + Readable, + StreamBase as Stream, + Transform, + Writable, + addAbortSignal, + compose, + destroy, + finished, + getDefaultHighWaterMark, + _isArrayBufferView, + isDestroyed, + isDisturbed, + isErrored, + isReadable, + isWritable, + pipeline, + promises, + setDefaultHighWaterMark, +} + +/** CommonJS interop marker consumed by the worker module loader. */ +export const __esModule = true + +/** CommonJS-compatible namespace for default imports. */ +export default streamDefault diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts deleted file mode 100644 index 7c30396bec..0000000000 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * `node:stream` stub. Every harness import of this module in the reachable tree - * is type-only (`Duplex`/`Readable`/`Writable` annotations), so nothing here runs - * unless a value import appears; then it says so. - */ -import { notImplementedFail } from '../../notImplementedFail.ts' - -const MODULE = 'node:stream' - -/** Readable stream (unavailable; use WHATWG ReadableStream). */ -export const Readable: typeof import('node:stream').Readable = notImplementedFail(MODULE, 'Readable') - -/** Writable stream (unavailable). */ -export const Writable: typeof import('node:stream').Writable = notImplementedFail(MODULE, 'Writable') - -/** Duplex stream (unavailable). */ -export const Duplex: typeof import('node:stream').Duplex = notImplementedFail(MODULE, 'Duplex') - -/** Transform stream (unavailable). */ -export const Transform: typeof import('node:stream').Transform = notImplementedFail(MODULE, 'Transform') - -/** PassThrough stream (unavailable). */ -export const PassThrough: typeof import('node:stream').PassThrough = notImplementedFail(MODULE, 'PassThrough') - -/** Pipeline helper (unavailable). */ -export const pipeline: typeof import('node:stream').pipeline = notImplementedFail(MODULE, 'pipeline') - -/** Finished helper (unavailable). */ -export const finished: typeof import('node:stream').finished = notImplementedFail(MODULE, 'finished') - -/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ -export const __esModule = true - -/** The `node:stream` declarations this module stands in for. */ -type NodeFace = Partial - -/** CommonJS default export: the members `require()` hands a caller of this module. */ -export default { Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished } satisfies NodeFace diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index f7008b518d..a2260d9831 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -32,6 +32,7 @@ import * as nodeModule from './builtin_modules/implemented/module.ts' import * as nodeOs from './builtin_modules/implemented/os.ts' import * as nodePath from './builtin_modules/implemented/path.ts' import * as nodePerfHooks from './builtin_modules/implemented/perf_hooks.ts' +import * as nodeStream from './builtin_modules/implemented/stream.ts' import * as nodeTimersPromises from './builtin_modules/implemented/timers/promises.ts' import * as nodeUrl from './builtin_modules/implemented/url.ts' import * as nodeUtil from './builtin_modules/implemented/util.ts' @@ -40,12 +41,9 @@ import * as nodeZlib from './builtin_modules/implemented/zlib.ts' import * as nodeChildProcess from './builtin_modules/implemented/child_process.ts' import * as nodeNet from './builtin_modules/mock/net.ts' import * as nodeSqlite from './builtin_modules/mock/sqlite.ts' -import * as nodeStream from './builtin_modules/mock/stream.ts' import * as nodeVm from './builtin_modules/mock/vm.ts' import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts' -import * as chokidar from './external_packages/chokidar.ts' import * as koffi from './external_packages/koffi.ts' -import * as landlockRun from './external_packages/node-addon-landlock-run.ts' import * as nodePty from './external_packages/node-pty.ts' import * as piAi from './external_packages/pi-ai.ts' import * as ripgrep from './external_packages/ripgrep.ts' @@ -83,14 +81,12 @@ const BUILTINS: Record = { /** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */ const EXTERNALS: Record = { - 'chokidar': () => chokidar, 'koffi': () => koffi, 'sharp': () => sharp, 'node-pty': () => nodePty, 'ws': () => ws, '@vscode/ripgrep': () => ripgrep, '@earendil-works/pi-ai': () => piAi, - '@deepseek-ai/node-addon-landlock-run': () => landlockRun, } /** diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts b/packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts deleted file mode 100644 index b8c96ccd65..0000000000 --- a/packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * `chokidar` stub: a constructible watcher that never fires. Settings and - * credentials call `watch()` unconditionally in `[Service.init]`, and the - * in-memory VFS has no external writer, so "no events" is the truth here rather - * than a degradation. - */ - -/** No-op watcher with chokidar's chainable face. */ -export class FSWatcher { - /** - * Register a listener; no event is ever emitted. - * @returns this watcher. - */ - on(): this { - return this - } - - /** - * Register a one-shot listener; no event is ever emitted. - * @returns this watcher. - */ - once(): this { - return this - } - - /** - * Add paths to the (inert) watch set. - * @returns this watcher. - */ - add(): this { - return this - } - - /** - * Remove paths from the (inert) watch set. - * @returns this watcher. - */ - unwatch(): this { - return this - } - - /** - * Watched paths, as chokidar reports them. - * @returns An empty record; nothing is ever watched. - */ - getWatched(): Record { - return {} - } - - /** Close the watcher. */ - async close(): Promise { - // Nothing was ever watched. - } -} - -/** - * Create an inert watcher. - * @returns the watcher. - */ -export function watch(): FSWatcher { - return new FSWatcher() -} - -/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ -export const __esModule = true - -/** CommonJS default export: the members `require()` hands a caller of this module. */ -export default { watch, FSWatcher } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts b/packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts deleted file mode 100644 index b963837cb4..0000000000 --- a/packages/experimental/webworker-runtime/src/node/external_packages/node-addon-landlock-run.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * `@deepseek-ai/node-addon-landlock-run` stub: the Landlock launcher. Sandboxing - * is part of the declared excluded surface, so `sandbox-local` mounts with the - * launcher path and probe present and fails when it tries to confine a process. - */ -import { notImplementedFail } from '../notImplementedFail.ts' - -const MODULE = '@deepseek-ai/node-addon-landlock-run' - -/** Launcher executable name, read at module scope by sandbox-local. */ -export const LAUNCHER_BIN = 'landlock-run' - -/** Exit code the launcher reports when confinement itself fails. */ -export const LAUNCHER_FAILURE_EXIT = 126 - -/** - * Path of the launcher binary; nothing in a browser can execute it. - * @returns The image path consumers read before failing on their own terms. - */ -export function launcherPath(): string { - return `/dsh/bin/${LAUNCHER_BIN}` -} - -/** Landlock availability probe (unavailable). */ -export const probe = notImplementedFail(MODULE, 'probe') - -/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ -export const __esModule = true - -/** CommonJS default export: the members `require()` hands a caller of this module. */ -export default { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, launcherPath, probe } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts index 6332f8af02..528e6f6885 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts @@ -8,10 +8,8 @@ /** External packages served from the worker bundle instead of the VFS. */ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [ - '@deepseek-ai/node-addon-landlock-run', '@earendil-works/pi-ai', '@vscode/ripgrep', - 'chokidar', 'koffi', 'node-pty', 'sharp', diff --git a/packages/experimental/webworker-runtime/src/shell/fs-access.ts b/packages/experimental/webworker-runtime/src/shell/fs-access.ts index 64d2a6a895..48da48302a 100644 --- a/packages/experimental/webworker-runtime/src/shell/fs-access.ts +++ b/packages/experimental/webworker-runtime/src/shell/fs-access.ts @@ -57,7 +57,8 @@ export function describeFailure(program: string, path: string, error: unknown): * @returns the error to throw. */ export function filesystemError(code: string, syscall: string, path: string): VfsError { - const error = new Error(`${code}: ${syscall} failed, ${syscall} '${path}'`) as VfsError + const reason = code === 'EACCES' ? 'permission denied' : `${syscall} failed` + const error = new Error(`${code}: ${reason}, ${syscall} '${path}'`) as VfsError error.code = code error.path = path error.syscall = syscall @@ -75,7 +76,6 @@ function statsOf(stats: VfsStats): ShellStats { */ export function hostFileSystem(): ShellFileSystem { const vfs = (): ReturnType => requireActiveVfs() - // oxlint-disable-next-line typescript/require-await -- async face, in-memory backend; see the note below. const stat = async (path: string): Promise => { try { return statsOf(vfs().statSync(path) as VfsStats) @@ -87,7 +87,6 @@ export function hostFileSystem(): ShellFileSystem { } // Several members take no await: the face is asynchronous because a process // worker's filesystem is, while this backend answers from memory. - /* oxlint-disable typescript/require-await -- see the note above. */ return { stat, list: async (path: string): Promise => { @@ -116,5 +115,4 @@ export function hostFileSystem(): ShellFileSystem { vfs().renameSync(from, to) }, } - /* oxlint-enable typescript/require-await */ } diff --git a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts new file mode 100644 index 0000000000..319ee6b983 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts @@ -0,0 +1,188 @@ +/** Landlock launcher parsing and per-process VFS enforcement for the worker shell. */ +import { resolve } from '../../module-system/posix-path.ts' +import { DSH_TMP } from '../../storage/paths.ts' +import { filesystemError } from '../fs-access.ts' +import type { ShellDirent, ShellFileSystem, ShellStats } from '../types.ts' +import type { VirtualExecutable, VirtualExecutableExit } from './virtual-executables.ts' + +/** Parsed invocation of the native launcher's unchanged argv grammar. */ +export type LandlockInvocation = + | { readonly kind: 'probe' } + | { + readonly kind: 'run' + readonly readOnly: readonly string[] + readonly readWrite: readonly string[] + readonly argv: readonly string[] + } + +/** Launcher-owned failure; callers print its message with the `landlock-run:` prefix. */ +export class LandlockLauncherError extends Error {} + +/** + * Parse the native launcher's argv grammar. + * @param args - Arguments after the launcher executable. + * @returns A probe or confined-run request. + */ +export function parseLandlockArguments(args: readonly string[]): LandlockInvocation { + const readOnly: string[] = [] + const readWrite: string[] = [] + for (let index = 0; index < args.length;) { + const argument = args[index] as string + if (argument === '--probe') { + if (args.length !== 1) throw new LandlockLauncherError('usage error: --probe takes no other arguments') + return { kind: 'probe' } + } + if (argument === '--ro' || argument === '--rw') { + const path = args[index + 1] + if (path === undefined) throw new LandlockLauncherError(`usage error: ${argument} requires a path`) + ;(argument === '--ro' ? readOnly : readWrite).push(path) + index += 2 + continue + } + if (argument === '--') { + const argv = args.slice(index + 1) + if (argv.length === 0) throw new LandlockLauncherError('usage error: missing `-- ...` command') + return { kind: 'run', readOnly, readWrite, argv } + } + throw new LandlockLauncherError(`usage error: unknown argument: ${argument}`) + } + throw new LandlockLauncherError('usage error: missing `-- ...` command') +} + +/** Map the host launcher's temp path into the Worker VFS. */ +function vfsPath(path: string, cwd: string): string { + const absolute = resolve(cwd, path) + if (absolute === '/tmp') return DSH_TMP + if (absolute.startsWith('/tmp/')) return `${DSH_TMP}${absolute.slice('/tmp'.length)}` + return absolute +} + +/** Whether a normalized path is the root itself or one of its descendants. */ +function contains(root: string, path: string): boolean { + return root === '/' || path === root || path.startsWith(`${root}/`) +} + +/** Throw the denial dialect consumed by `dsh-bash-sandbox`. */ +function deny(syscall: string, path: string): never { + throw filesystemError('EACCES', syscall, path) +} + +/** Stats for the virtual `/dev/null` file. */ +const NULL_STATS: ShellStats = { directory: false, size: 0, mtimeMs: 0 } +const DEV_ROOT = '/dev' +const NULL_PATH = '/dev/null' + +/** Build one launcher-owned terminal result. */ +function launcherExit(exitCode: number, stdout = '', stderr = ''): VirtualExecutableExit { + return { kind: 'exit', exitCode, stdout, stderr } +} + +/** Convert a parser or grant failure into the native launcher's fatal dialect. */ +function launcherFailure(error: unknown): VirtualExecutableExit { + const detail = error instanceof LandlockLauncherError ? error.message : String(error) + return launcherExit(125, '', `landlock-run: ${detail}\n`) +} + +/** + * Validate grant roots and create one process-local filesystem guard. + * @param base - Host-side VFS adapter all permitted calls delegate to. + * @param invocation - Parsed confined-run request. + * @param cwd - Launcher's working directory for relative grant paths. + * @returns A filesystem enforcing only this invocation's grants. + */ +export async function landlockFileSystem( + base: ShellFileSystem, + invocation: Extract, + cwd: string, +): Promise { + const normalizeGrant = async (path: string): Promise => { + if (path === '') throw new LandlockLauncherError('cannot open rule path: : No such file or directory') + const target = vfsPath(path, cwd) + if (target !== DEV_ROOT && target !== NULL_PATH && await base.stat(target) === undefined) { + throw new LandlockLauncherError(`cannot open rule path: ${path}: No such file or directory`) + } + return target + } + const readOnly = await Promise.all(invocation.readOnly.map(normalizeGrant)) + const readWrite = await Promise.all(invocation.readWrite.map(normalizeGrant)) + const readable = [...readOnly, ...readWrite] + + const readPath = (path: string, syscall: string): string => { + const target = vfsPath(path, cwd) + if (!readable.some(root => contains(root, target))) deny(syscall, path) + return target + } + const writePath = (path: string, syscall: string): string => { + const target = vfsPath(path, cwd) + if (!readWrite.some(root => contains(root, target))) deny(syscall, path) + return target + } + + return { + stat: async (path: string): Promise => { + const target = readPath(path, 'stat') + if (target === NULL_PATH) return NULL_STATS + if (target === DEV_ROOT && !await base.stat(target)) return { directory: true, size: 0, mtimeMs: 0 } + return await base.stat(target) + }, + list: async (path: string): Promise => { + const target = readPath(path, 'scandir') + if (target === DEV_ROOT) return [{ name: 'null', directory: false }] + if (target === NULL_PATH) throw filesystemError('ENOTDIR', 'scandir', path) + return await base.list(target) + }, + readText: async (path: string): Promise => { + const target = readPath(path, 'open') + return target === NULL_PATH ? '' : await base.readText(target) + }, + writeText: async (path: string, text: string, append = false): Promise => { + const target = writePath(path, 'open') + if (target !== NULL_PATH) await base.writeText(target, text, append) + }, + mkdir: async (path: string, recursive: boolean): Promise => { + const target = writePath(path, 'mkdir') + if (target === NULL_PATH) throw filesystemError('EEXIST', 'mkdir', path) + await base.mkdir(target, recursive) + }, + remove: async (path: string, options: { recursive: boolean; force: boolean }): Promise => { + const target = writePath(path, 'rm') + if (target === NULL_PATH) deny('rm', path) + await base.remove(target, options) + }, + rename: async (from: string, to: string): Promise => { + const source = writePath(from, 'rename') + const destination = writePath(to, 'rename') + if (source === NULL_PATH || destination === NULL_PATH) deny('rename', source === NULL_PATH ? from : to) + await base.rename(source, destination) + }, + } +} + +/** Virtual executable implementing the native launcher's CLI over VFS grants. */ +export const LANDLOCK_EXECUTABLE: VirtualExecutable = { + name: 'landlock-run', + async prepare(args, context) { + try { + const invocation = parseLandlockArguments(args) + if (invocation.kind === 'probe') return launcherExit(0, 'landlock: fully enforced\n') + return { + kind: 'delegate', + argv: invocation.argv, + filesystem: await landlockFileSystem(context.filesystem, invocation, context.cwd), + missingExecutable: launcherExit(125, '', 'landlock-run: exec failed: No such file or directory\n'), + } + } catch (error) { + return launcherFailure(error) + } + }, + runSync(args) { + try { + const invocation = parseLandlockArguments(args) + return invocation.kind === 'probe' + ? launcherExit(0, 'landlock: fully enforced\n') + : { kind: 'asynchronous' } + } catch (error) { + return launcherFailure(error) + } + }, +} diff --git a/packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts b/packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts new file mode 100644 index 0000000000..6f04cf1865 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/shell/process/virtual-executables.ts @@ -0,0 +1,61 @@ +/** Virtual executable registry used by the Worker process launcher. */ +import { basename } from '../../module-system/posix-path.ts' +import type { ShellFileSystem } from '../types.ts' +import { LANDLOCK_EXECUTABLE } from './landlock.ts' + +/** Completed virtual executable invocation. */ +export interface VirtualExecutableExit { + readonly kind: 'exit' + readonly exitCode: number + readonly stdout: string + readonly stderr: string +} + +/** Invocation delegated to the normal Worker command runner after preparation. */ +export interface VirtualExecutableDelegate { + readonly kind: 'delegate' + readonly argv: readonly string[] + readonly filesystem: ShellFileSystem + readonly missingExecutable: VirtualExecutableExit +} + +/** Result of preparing an asynchronous virtual executable invocation. */ +export type VirtualExecutablePreparation = VirtualExecutableExit | VirtualExecutableDelegate + +/** Result available to the synchronous child-process face. */ +export type VirtualExecutableSyncResult = VirtualExecutableExit | { readonly kind: 'asynchronous' } + +/** One executable implemented by the Worker instead of an operating-system binary. */ +export interface VirtualExecutable { + /** Platform executable name, independent of package-manager installation path. */ + readonly name: string + /** + * Prepare an invocation or complete it without entering the command runner. + * @param args - Arguments after the executable path. + * @param context - Working directory and ambient Worker filesystem. + * @returns The completed result or delegated command and filesystem. + */ + prepare( + args: readonly string[], + context: { readonly cwd: string; readonly filesystem: ShellFileSystem }, + ): Promise + /** + * Handle the subset that can complete synchronously. + * @param args - Arguments after the executable path. + * @returns A completed result or the asynchronous marker. + */ + runSync(args: readonly string[]): VirtualExecutableSyncResult +} + +const EXECUTABLES: ReadonlyMap = new Map([ + [LANDLOCK_EXECUTABLE.name, LANDLOCK_EXECUTABLE], +]) + +/** + * Resolve a Worker platform executable by logical name. + * @param path - Bare name or executable path passed to `spawn`. + * @returns Its implementation, or undefined for the normal command table. + */ +export function virtualExecutable(path: string): VirtualExecutable | undefined { + return EXECUTABLES.get(basename(path)) +} diff --git a/packages/experimental/webworker-runtime/src/storage/active.ts b/packages/experimental/webworker-runtime/src/storage/active.ts index ce54337b21..74cfc63b41 100644 --- a/packages/experimental/webworker-runtime/src/storage/active.ts +++ b/packages/experimental/webworker-runtime/src/storage/active.ts @@ -4,15 +4,15 @@ * which backend the worker entry mounted. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active */ -import type { MemoryVfs } from './memory.ts' +import type { Vfs } from './types.ts' -let active: MemoryVfs | undefined +let active: Vfs | undefined /** * Publish the filesystem the `node:fs` proxy reads. * @param vfs - Filesystem mounted by the worker entry. */ -export function setActiveVfs(vfs: MemoryVfs): void { +export function setActiveVfs(vfs: Vfs): void { active = vfs } @@ -20,7 +20,7 @@ export function setActiveVfs(vfs: MemoryVfs): void { * Read the mounted filesystem. * @returns The active filesystem. */ -export function requireActiveVfs(): MemoryVfs { +export function requireActiveVfs(): Vfs { if (active === undefined) { throw new Error('webworker vfs: no filesystem is mounted; the worker entry must call setActiveVfs before any node:fs access') } diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index f4a544c001..96a63f859f 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -1,14 +1,14 @@ /** * In-memory filesystem behind the worker's `node:fs` proxy. Contents come from - * the build-time image (see {@link loadVfsImage}); writes stay in memory and - * vanish with the worker. + * the build-time image (see {@link loadVfsImage}); this remains the synchronous + * authority when an asynchronous durable sink mirrors selected subtrees. * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory */ import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts' import { parseTar } from './tar.ts' import type { - VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsReadOptions, VfsStatOptions, - VfsStats, VfsWriteOptions, + Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, + VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats, VfsWriteOptions, } from './types.ts' const decoder = new TextDecoder() @@ -46,10 +46,14 @@ function encodingOf(options: VfsReadOptions): VfsEncoding | undefined { // stored value — the round-trip consumers like dsh-credentials-local's // owner-only check rely on. The bits are never enforced: a single-owner // filesystem reads and writes as its owner regardless, like root. -function statsOf(size: number, mtimeMs: number, directory: boolean, mode: number): VfsStats { +function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsStats { return { size, + ino: Number(ino), mtimeMs, + ctimeMs: mtimeMs, + atimeMs: mtimeMs, + birthtimeMs: mtimeMs, mtime: new Date(mtimeMs), mode: (directory ? 0o040000 : 0o100000) | (mode & 0o777), isFile: () => !directory, @@ -107,16 +111,26 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b } } +/** Construction inputs for {@link MemoryVfs}. */ +export interface MemoryVfsOptions { + /** Durable write-behind observer; absent leaves the filesystem ephemeral. */ + readonly sink?: VfsMutationSink +} + /** * Filesystem held in two maps: one for file bytes, one for directories. * Every path is normalized to an absolute POSIX path without a trailing * separator, so callers may pass either form. */ -export class MemoryVfs { +export class MemoryVfs implements Vfs { private readonly files = new Map() private readonly directories = new Set([SEP]) /** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */ private readonly directoryModes = new Map() + /** Directory mtimes advance when their immediate entry set changes. */ + private readonly directoryMtimes = new Map() + private readonly mutationListeners = new Set() + private readonly sink: VfsMutationSink | undefined private temporaries = 0 // Identity per path, assigned on first stat and dropped when the path goes: // the filesystem service builds its version token from `ino` plus the @@ -124,6 +138,47 @@ export class MemoryVfs { private readonly identities = new Map() private lastIdentity = 0n + /** + * Build the synchronous filesystem authority. + * @param options - Optional durable write-behind sink. + */ + constructor(options: MemoryVfsOptions = {}) { + this.sink = options.sink + } + + /** + * Settle the durable sink without changing in-memory success. + * @returns A promise that resolves when all recorded mutations are stored. + */ + async flush(): Promise { + await this.sink?.flush() + } + + /** + * Observe committed runtime mutations. Image seeding is deliberately silent. + * @param listener - Consumer called after each successful mutation. + * @returns A disposer that prevents future calls. + */ + subscribe(listener: VfsMutationListener): () => void { + this.mutationListeners.add(listener) + return () => { this.mutationListeners.delete(listener) } + } + + /** Publish after state changes; one faulty observer cannot roll back a write. */ + private publish(mutation: VfsMutation): void { + const observers: VfsMutationListener[] = [ + ...(this.sink === undefined ? [] : [(change: VfsMutation): void => { this.sink?.record(change) }]), + ...this.mutationListeners, + ] + for (const listener of observers) { + try { + listener(mutation) + } catch (error) { + console.error('webworker vfs: mutation observer failed', error) + } + } + } + /** Promise face mirroring `node:fs/promises` for the methods the roster uses. */ readonly promises = { readFile: async (path: string, options?: VfsReadOptions): Promise => this.readFileSync(path, options), @@ -198,11 +253,12 @@ export class MemoryVfs { const [size, mtimeMs, directory, mode] = node !== undefined ? [node.bytes.length, node.mtimeMs, false, node.mode] as const : this.directories.has(target) - ? [0, 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const + ? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const : fail('ENOENT', 'stat', target) + const identity = this.identityOf(target) return options?.bigint === true - ? bigIntStatsOf(size, mtimeMs, directory, this.identityOf(target), mode) - : statsOf(size, mtimeMs, directory, mode) + ? bigIntStatsOf(size, mtimeMs, directory, identity, mode) + : statsOf(size, mtimeMs, directory, identity, mode) } /** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */ @@ -244,6 +300,13 @@ export class MemoryVfs { return previous === undefined ? now : Math.max(now, previous + 1) } + /** Advance a directory's mtime after its immediate children change. */ + private touchDirectory(target: string): void { + const previous = this.directoryMtimes.get(target) + const now = Date.now() + this.directoryMtimes.set(target, previous === undefined ? now : Math.max(now, previous + 1)) + } + /** * List a directory. * @param path - Directory path. @@ -312,7 +375,11 @@ export class MemoryVfs { this.mkdirSync(parent, options) } this.directories.add(target) - if (options?.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777) + this.touchDirectory(target) + this.touchDirectory(parent) + const mode = (options?.mode ?? DEFAULT_DIRECTORY_MODE) & 0o777 + if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode) + this.publish({ kind: 'mkdir', path: target, mode }) return target } @@ -331,8 +398,14 @@ export class MemoryVfs { if (flag.startsWith('a')) { this.appendFileSync(target, data); return } // POSIX open(O_CREAT): the mode applies at creation only; a rewrite keeps // the entry's bits. - const mode = this.files.get(target)?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE) - this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target), mode }) + const previous = this.files.get(target) + const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE) + const bytes = typeof data === 'string' ? encoder.encode(data) : data + this.files.set(target, { bytes, mtimeMs: this.touch(target), mode }) + if (previous === undefined) this.touchDirectory(dirname(target)) + this.publish({ + kind: 'write', path: target, bytes, mode, entryChanged: previous === undefined, + }) } /** @@ -405,7 +478,9 @@ export class MemoryVfs { truncate: async (length = 0): Promise => { const node = this.files.get(target) if (node === undefined) fail('ENOENT', 'ftruncate', target) - this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target), mode: node.mode }) + const bytes = node.bytes.slice(0, length) + this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) + this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) }, ...this.handleTail(target), } @@ -414,17 +489,17 @@ export class MemoryVfs { /** * The handle members that do not depend on how the file was opened. * - * `sync`/`datasync` have nothing to flush — the bytes are already the stored - * ones — and `close` releases nothing, so both directory and file handles - * share this tail. + * `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem + * resolves immediately. `close` releases nothing, so both directory and file + * handles share this tail. * @param target - Normalized path the handle was opened on. * @returns Metadata plus the no-op durability and release calls. */ private handleTail(target: string): Pick { return { stat: async (): Promise => this.plainStats(target), - sync: async (): Promise => {}, - datasync: async (): Promise => {}, + sync: async (): Promise => { await this.flush() }, + datasync: async (): Promise => { await this.flush() }, close: async (): Promise => {}, } } @@ -443,6 +518,10 @@ export class MemoryVfs { merged.set(existing.bytes) merged.set(addition, existing.bytes.length) this.files.set(target, { bytes: merged, mtimeMs: this.touch(target), mode: existing.mode }) + this.publish({ + kind: 'write', path: target, bytes: merged, mode: existing.mode, + entryChanged: false, appendedFrom: existing.bytes.length, + }) } /** @@ -460,15 +539,23 @@ export class MemoryVfs { this.files.set(destination, node) this.forgetIdentity(source) this.forgetIdentity(destination) + this.touchDirectory(dirname(source)) + this.touchDirectory(dirname(destination)) + this.publish({ kind: 'remove', path: source }) + this.publish({ kind: 'write', path: destination, bytes: node.bytes, mode: node.mode, entryChanged: true }) return } if (!this.directories.has(source)) fail('ENOENT', 'rename', source) const prefix = `${source}${SEP}` + const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = [] for (const [candidate, value] of [...this.files]) { if (!candidate.startsWith(prefix)) continue this.files.delete(candidate) - this.files.set(join(destination, candidate.slice(prefix.length)), value) + const target = join(destination, candidate.slice(prefix.length)) + this.files.set(target, value) + movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode }) } + const movedDirectories: Array<{ path: string; mode: number }> = [] for (const candidate of [...this.directories]) { if (!candidate.startsWith(prefix) && candidate !== source) continue const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length)) @@ -477,9 +564,24 @@ export class MemoryVfs { const bits = this.directoryModes.get(candidate) this.directoryModes.delete(candidate) if (bits !== undefined) this.directoryModes.set(moved, bits) + movedDirectories.push({ path: moved, mode: bits ?? DEFAULT_DIRECTORY_MODE }) + const mtime = this.directoryMtimes.get(candidate) + this.directoryMtimes.delete(candidate) + if (mtime !== undefined) this.directoryMtimes.set(moved, mtime) } this.forgetIdentity(source) this.forgetIdentity(destination) + this.touchDirectory(dirname(source)) + this.touchDirectory(dirname(destination)) + this.publish({ kind: 'remove', path: source }) + for (const directory of movedDirectories) { + this.publish({ kind: 'mkdir', path: directory.path, mode: directory.mode }) + } + for (const entry of movedFiles) { + this.publish({ + kind: 'write', path: entry.path, bytes: entry.bytes, mode: entry.mode, entryChanged: true, + }) + } } /** @@ -499,6 +601,8 @@ export class MemoryVfs { if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target) if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target) this.files.set(target, node) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true }) } /** @@ -510,7 +614,9 @@ export class MemoryVfs { const target = this.key(path) const node = this.files.get(target) if (node === undefined) fail('ENOENT', 'truncate', target) - this.files.set(target, { bytes: node.bytes.slice(0, length), mtimeMs: this.touch(target), mode: node.mode }) + const bytes = node.bytes.slice(0, length) + this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) + this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) } /** @@ -523,10 +629,13 @@ export class MemoryVfs { const node = this.files.get(target) if (node !== undefined) { node.mode = mode & 0o777 + this.publish({ kind: 'chmod', path: target, mode: node.mode }) return } if (this.directories.has(target)) { - this.directoryModes.set(target, mode & 0o777) + const bits = mode & 0o777 + this.directoryModes.set(target, bits) + this.publish({ kind: 'chmod', path: target, mode: bits }) return } fail('ENOENT', 'chmod', target) @@ -540,6 +649,8 @@ export class MemoryVfs { const target = this.key(path) if (!this.files.delete(target)) fail('ENOENT', 'unlink', target) this.forgetIdentity(target) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'remove', path: target }) } /** @@ -551,6 +662,8 @@ export class MemoryVfs { const target = this.key(path) if (this.files.delete(target)) { this.forgetIdentity(target) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'remove', path: target }) return } if (this.directories.has(target)) { @@ -561,10 +674,14 @@ export class MemoryVfs { if (!candidate.startsWith(prefix)) continue this.directories.delete(candidate) this.directoryModes.delete(candidate) + this.directoryMtimes.delete(candidate) } this.directories.delete(target) this.directoryModes.delete(target) + this.directoryMtimes.delete(target) this.forgetIdentity(target) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'remove', path: target }) return } if (options?.force !== true) fail('ENOENT', 'rm', target) @@ -586,23 +703,36 @@ export class MemoryVfs { * Seed a file and its parent directories, for image loading and tests. * @param path - File path. * @param data - Text or bytes. - * @param mode - Permission bits recorded for the entry. + * @param options - Permission bits and modification time supplied by the image or durable store. */ - seed(path: string, data: string | Uint8Array, mode = DEFAULT_FILE_MODE): void { + seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void { const target = this.key(path) - this.mkdirSync(dirname(target), { recursive: true }) - this.files.set(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: this.touch(target), mode: mode & 0o777 }) + this.seedDirectory(dirname(target)) + this.files.set(target, { + bytes: typeof data === 'string' ? encoder.encode(data) : data, + mtimeMs: options.mtimeMs ?? this.touch(target), + mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777, + }) + this.touchDirectory(dirname(target)) } /** * Create a directory and its parents. * @param path - Directory path. - * @param mode - Permission bits recorded for the directory itself. + * @param options - Permission bits and modification time supplied by the image or durable store. */ - seedDirectory(path: string, mode = DEFAULT_DIRECTORY_MODE): void { + seedDirectory(path: string, options: VfsSeedOptions = {}): void { const target = this.key(path) - this.mkdirSync(target, { recursive: true }) - if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode & 0o777) + if (!this.directories.has(target)) { + const parent = dirname(target) + if (parent !== target) this.seedDirectory(parent) + if (this.files.has(target)) fail('EEXIST', 'mkdir', target) + this.directories.add(target) + this.directoryMtimes.set(target, options.mtimeMs ?? Date.now()) + this.touchDirectory(parent) + } + if (options.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777) + if (options.mtimeMs !== undefined) this.directoryMtimes.set(target, options.mtimeMs) } /** @@ -636,10 +766,10 @@ export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryV } const target = join(root, relativeName) if (entry.directory) { - vfs.seedDirectory(target, entry.mode) + vfs.seedDirectory(target, { mode: entry.mode }) continue } - vfs.seed(target, entry.bytes, entry.mode) + vfs.seed(target, entry.bytes, { mode: entry.mode }) } return vfs } diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index 9208761407..e879d5f11f 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -22,7 +22,12 @@ export interface VfsError extends Error { /** Subset of `fs.Stats` the roster reads. */ export interface VfsStats { readonly size: number + /** Stable identity while an entry exists; recreation receives another value. */ + readonly ino: number readonly mtimeMs: number + readonly ctimeMs: number + readonly atimeMs: number + readonly birthtimeMs: number readonly mtime: Date readonly mode: number isFile(): boolean @@ -85,6 +90,12 @@ export interface VfsWriteOptions { readonly flag?: string } +/** Explicit metadata for image or durable-store hydration. */ +export interface VfsSeedOptions { + readonly mode?: number + readonly mtimeMs?: number +} + /** Directory entry as `readdir` with `withFileTypes` reports it. */ export interface VfsDirent { readonly name: string @@ -113,3 +124,90 @@ export interface VfsFileHandle { datasync(): Promise close(): Promise } + +/** + * One completed change to the authoritative in-memory filesystem. + * + * A durable mirror receives the post-write bytes, virtual permission bits, and optional append offset; + * live watchers use `entryChanged` to distinguish directory-entry replacement + * from content writes. Rename is represented as source removal plus complete + * destination mkdir/write records, so a sink never receives a path without the + * state needed to materialize it. + */ +export type VfsMutation = + | { + readonly kind: 'write' + readonly path: string + readonly bytes: Uint8Array + readonly mode: number + readonly entryChanged: boolean + readonly appendedFrom?: number + } + | { readonly kind: 'mkdir'; readonly path: string; readonly mode: number } + | { readonly kind: 'remove'; readonly path: string } + | { readonly kind: 'chmod'; readonly path: string; readonly mode: number } + +/** Receives one committed VFS mutation. */ +export type VfsMutationListener = (mutation: VfsMutation) => void + +/** Durable observer attached to the synchronous VFS. */ +export interface VfsMutationSink { + /** + * Record one completed mutation without delaying its caller. + * @param mutation - Post-commit state to mirror. + */ + record(mutation: VfsMutation): void + /** + * Settle all previously recorded mutations. + * Implementations report persistence failures and stop mirroring rather than + * rejecting, because the in-memory mutation has already committed. + * @returns A promise that resolves when the sink has no pending work. + */ + flush(): Promise +} + +/** Synchronous filesystem used by the worker's Node compatibility modules. */ +export interface Vfs { + readonly promises: { + readFile(path: string, options?: VfsReadOptions): Promise + writeFile(path: string, data: string | Uint8Array, options?: VfsWriteOptions): Promise + appendFile(path: string, data: string | Uint8Array): Promise + mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise + readdir(path: string, options?: { withFileTypes?: boolean }): Promise + stat(path: string, options?: VfsStatOptions): Promise + lstat(path: string, options?: VfsStatOptions): Promise + realpath(path: string): Promise + rename(from: string, to: string): Promise + unlink(path: string): Promise + rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise + mkdtemp(prefix: string): Promise + link(existing: string, next: string): Promise + truncate(path: string, length?: number): Promise + chmod(path: string, mode: number): Promise + opendir(path: string): Promise + open(path: string, flags?: string, mode?: number): Promise + access(path: string): Promise + } + readFileSync(path: string, options?: VfsReadOptions): string | Uint8Array + existsSync(path: string): boolean + statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats + readdirSync(path: string, options?: { withFileTypes?: boolean }): string[] & VfsDirent[] + realpathSync(path: string): string + mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): string | undefined + writeFileSync(path: string, data: string | Uint8Array, options?: VfsWriteOptions): void + appendFileSync(path: string, data: string | Uint8Array): void + renameSync(from: string, to: string): void + linkSync(existing: string, next: string): void + truncateSync(path: string, length?: number): void + chmodSync(path: string, mode: number): void + unlinkSync(path: string): void + rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void + mkdtempSync(prefix: string): string + seed(path: string, data: string | Uint8Array, options?: VfsSeedOptions): void + seedDirectory(path: string, options?: VfsSeedOptions): void + usage(): { files: number; directories: number; bytes: number } + /** Register one observer and return its synchronous disposer. */ + subscribe(listener: VfsMutationListener): () => void + /** Settle the attached durable mutation sink, if any. */ + flush(): Promise +} diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index 619f95f3ce..6a5826a19a 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -16,13 +16,22 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts' import { spawn, spawnSync } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts' +import { + LAUNCHER_FAILURE_EXIT, grantArgs, launcherPath, probe, +} from '@deepseek-ai/node-addon-landlock-run' import { processAlive, signalProcess } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/process-table.ts' +import { hostFileSystem } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/fs-access.ts' +import { + LANDLOCK_EXECUTABLE, landlockFileSystem, parseLandlockArguments, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/src/shell/process/landlock.ts' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' vi.mock('node:child_process', async () => await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process.ts')) const WORKSPACE = '/dsh/workspace' +const HOME = '/dsh/home' +const TMP = '/dsh/tmp' let vfs: MemoryVfs @@ -30,6 +39,8 @@ beforeEach(() => { vfs = new MemoryVfs() setActiveVfs(vfs) vfs.mkdirSync(WORKSPACE, { recursive: true }) + vfs.mkdirSync(HOME, { recursive: true }) + vfs.mkdirSync(TMP, { recursive: true }) vi.spyOn(process, 'kill').mockImplementation((pid: number, signal?: string | number): true => { if (signal === 0) { if (processAlive(pid)) return true @@ -91,6 +102,180 @@ it('refuses a command name that is not a string, as Node does', () => { it('reports that a synchronous run cannot happen, without throwing at the probe', () => { expect(spawnSync('bwrap').error?.code).toBe('ENOENT') expect(spawnSync('echo').error?.message).toContain('commands run asynchronously') + expect(spawnSync(launcherPath(), ['--probe'])).toMatchObject({ + status: 0, + stdout: Buffer.from('landlock: fully enforced\n'), + }) + expect(spawnSync(launcherPath(), ['--ro', '/', '--', 'echo', 'x']).error?.message) + .toContain('commands run asynchronously') + expect(spawnSync(launcherPath(), ['--probe', '--'])).toMatchObject({ + status: LAUNCHER_FAILURE_EXIT, + stderr: expect.any(Buffer), + }) +}) + +it('keeps the native Landlock package API and CLI failure contract', async () => { + expect(probe()).toBe('full') + expect(probe('/not-the-worker-launcher')).toBe('unusable') + expect(probe('/another-package-layout/bin/landlock-run')).toBe('full') + expect(launcherPath(() => '/ignored/package.json')).toBe('/ignored/bin/landlock-run') + expect(LAUNCHER_FAILURE_EXIT).toBe(125) + expect(await collect(spawn(launcherPath(), ['--probe']))).toEqual({ + stdout: 'landlock: fully enforced\n', stderr: '', code: 0, + }) + const malformed = spawn(launcherPath(), ['--rw'], { cwd: WORKSPACE }) + expect(await collect(malformed)).toEqual({ + stdout: '', + stderr: 'landlock-run: usage error: --rw requires a path\n', + code: 125, + }) + const missingGrant = spawn(launcherPath(), ['--rw', '/dsh/missing', '--', 'touch', `${WORKSPACE}/never`], { cwd: WORKSPACE }) + expect(await collect(missingGrant)).toEqual({ + stdout: '', + stderr: 'landlock-run: cannot open rule path: /dsh/missing: No such file or directory\n', + code: 125, + }) + expect(vfs.existsSync(`${WORKSPACE}/never`)).toBe(false) + const missingCommand = spawn(launcherPath(), ['--ro', '/', '--', 'not-a-program'], { cwd: WORKSPACE }) + expect(await collect(missingCommand)).toEqual({ + stdout: '', + stderr: 'landlock-run: exec failed: No such file or directory\n', + code: 125, + }) +}) + +it('enforces every ShellFileSystem operation and virtual device edge', async () => { + vfs.writeFileSync(`${HOME}/private.txt`, 'private\n') + const invocation = parseLandlockArguments([ + ...grantArgs({ readOnly: ['/dev'], readWrite: [WORKSPACE, '/dev/null'] }), '--', 'true', + ]) + if (invocation.kind !== 'run') throw new Error('expected a confined run invocation') + const guarded = await landlockFileSystem(hostFileSystem(), invocation, WORKSPACE) + + expect(await guarded.stat('/dev/null')).toEqual({ directory: false, size: 0, mtimeMs: 0 }) + expect(await guarded.stat('/dev')).toEqual({ directory: true, size: 0, mtimeMs: 0 }) + expect(await guarded.list('/dev')).toEqual([{ name: 'null', directory: false }]) + await expect(guarded.list('/dev/null')).rejects.toMatchObject({ code: 'ENOTDIR' }) + expect(await guarded.readText('/dev/null')).toBe('') + await guarded.writeText('/dev/null', 'discarded') + await expect(guarded.mkdir('/dev/null', false)).rejects.toMatchObject({ code: 'EEXIST' }) + await expect(guarded.remove('/dev/null', { recursive: false, force: false })).rejects.toMatchObject({ code: 'EACCES' }) + await expect(guarded.rename('/dev/null', `${WORKSPACE}/null`)).rejects.toMatchObject({ code: 'EACCES' }) + await expect(guarded.readText(`${HOME}/private.txt`)).rejects.toMatchObject({ code: 'EACCES' }) + + await guarded.mkdir('created', false) + await guarded.writeText('created/file', 'one') + await guarded.writeText('created/file', ' two', true) + expect(await guarded.readText(`${WORKSPACE}/created/file`)).toBe('one two') + expect(await guarded.list(`${WORKSPACE}/created`)).toEqual([{ name: 'file', directory: false }]) + await guarded.rename('created/file', 'created/moved') + await expect(guarded.rename('created/moved', '/dev/null')).rejects.toMatchObject({ code: 'EACCES' }) + await guarded.remove('created', { recursive: true, force: false }) + expect(vfs.existsSync(`${WORKSPACE}/created`)).toBe(false) +}) + +it('turns an unexpected virtual-launcher preparation failure into exit 125', async () => { + const base = hostFileSystem() + const result = await LANDLOCK_EXECUTABLE.prepare( + ['--ro', '/', '--', 'true'], + { + cwd: WORKSPACE, + filesystem: { ...base, stat: () => Promise.reject(new Error('storage unavailable')) }, + }, + ) + expect(result).toEqual({ + kind: 'exit', exitCode: 125, stdout: '', stderr: 'landlock-run: Error: storage unavailable\n', + }) +}) + +it.each([ + { args: [], message: 'missing `-- ...` command' }, + { args: ['--unknown', '--', 'true'], message: 'unknown argument: --unknown' }, + { args: ['--probe', '--'], message: '--probe takes no other arguments' }, + { args: ['--'], message: 'missing `-- ...` command' }, + { args: ['--rw', '', '--', 'true'], message: 'cannot open rule path' }, +])('rejects malformed Landlock argv before execution: $message', async ({ args, message }) => { + const child = spawn(launcherPath(), args, { cwd: WORKSPACE }) + const result = await collect(child) + expect(result.code).toBe(LAUNCHER_FAILURE_EXIT) + expect(result.stderr).toContain(message) +}) + +it('enforces read-only and workspace-write grants over the VFS', async () => { + vfs.writeFileSync(`${HOME}/readable.txt`, 'visible\n') + const readOnly = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), + '--', 'bash', '-c', `cat ${HOME}/readable.txt; echo discarded > /dev/null; echo denied > ${WORKSPACE}/denied.txt`, + ], { cwd: WORKSPACE }) + const strict = await collect(readOnly) + expect(strict.code).toBe(1) + expect(strict.stdout).toBe('visible\n') + expect(strict.stderr.toLowerCase()).toContain('permission denied') + expect(vfs.existsSync(`${WORKSPACE}/denied.txt`)).toBe(false) + + const workspaceWrite = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null', '/tmp', WORKSPACE] }), + '--', 'bash', '-c', `echo workspace > ${WORKSPACE}/allowed.txt; echo temporary > /tmp/temp.txt; cat /tmp/temp.txt`, + ], { cwd: WORKSPACE }) + expect(await collect(workspaceWrite)).toEqual({ stdout: 'temporary\n', stderr: '', code: 0 }) + expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n') + expect(vfs.readFileSync(`${TMP}/temp.txt`, 'utf8')).toBe('temporary\n') + expect(vfs.existsSync('/dev/null')).toBe(false) +}) + +it('normalizes relative grants and denies sibling-prefix escapes and unreadable paths', async () => { + vfs.mkdirSync(`${WORKSPACE}/nested`) + vfs.mkdirSync(`${WORKSPACE}-other`) + vfs.writeFileSync(`${HOME}/private.txt`, 'private\n') + const child = spawn(launcherPath(), [ + ...grantArgs({ readOnly: [WORKSPACE], readWrite: ['.'] }), + '--', 'bash', '-c', `echo kept > nested/relative.txt; echo escaped > ${WORKSPACE}-other/escape.txt; cat ${HOME}/private.txt`, + ], { cwd: WORKSPACE }) + const result = await collect(child) + expect(result.code).toBe(1) + expect(result.stderr.toLowerCase()).toContain('permission denied') + expect(vfs.readFileSync(`${WORKSPACE}/nested/relative.txt`, 'utf8')).toBe('kept\n') + expect(vfs.existsSync(`${WORKSPACE}-other/escape.txt`)).toBe(false) + expect(result.stdout).not.toContain('private') +}) + +it('presents the virtual device directory without storing it in the VFS', async () => { + const child = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), + '--', 'bash', '-c', 'ls /dev; cat /dev/null', + ], { cwd: WORKSPACE }) + expect(await collect(child)).toEqual({ stdout: 'null\n', stderr: '', code: 0 }) + expect(vfs.existsSync('/dev')).toBe(false) +}) + +it('requires both rename paths to be writable', async () => { + vfs.writeFileSync(`${WORKSPACE}/source.txt`, 'kept\n') + const child = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: [WORKSPACE] }), + '--', 'mv', `${WORKSPACE}/source.txt`, `${HOME}/moved.txt`, + ], { cwd: WORKSPACE }) + const result = await collect(child) + expect(result.code).toBe(1) + expect(result.stderr.toLowerCase()).toContain('permission denied') + expect(vfs.readFileSync(`${WORKSPACE}/source.txt`, 'utf8')).toBe('kept\n') + expect(vfs.existsSync(`${HOME}/moved.txt`)).toBe(false) +}) + +it('keeps concurrent Landlock grants process-local', async () => { + const strict = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), + '--', 'bash', '-c', `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`, + ], { cwd: WORKSPACE }) + const writable = spawn(launcherPath(), [ + ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null', WORKSPACE] }), + '--', 'bash', '-c', `echo allowed > ${WORKSPACE}/writable.txt`, + ], { cwd: WORKSPACE }) + const [strictResult, writableResult] = await Promise.all([collect(strict), collect(writable)]) + expect(strictResult.code).toBe(1) + expect(strictResult.stderr.toLowerCase()).toContain('permission denied') + expect(writableResult).toEqual({ stdout: '', stderr: '', code: 0 }) + expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false) + expect(vfs.readFileSync(`${WORKSPACE}/writable.txt`, 'utf8')).toBe('allowed\n') }) it('carries a command through the real local subprocess service', async () => { diff --git a/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts b/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts new file mode 100644 index 0000000000..82066b810a --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/chokidar.spec.ts @@ -0,0 +1,210 @@ +/** Upstream Chokidar running unchanged through the shipped Worker module loader. */ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { lowerModuleSource } from '../../src/compile/transform.ts' +import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts' +import { createNodeBuiltins } from '../../src/node/builtins.ts' +import { MemoryVfs } from '../../src/storage/memory.ts' +import { setActiveVfs } from '../../src/storage/active.ts' + +const ROOT = '/dsh/workspace/skills' +let vfs: MemoryVfs +let chokidar: typeof import('chokidar') +const openWatchers: import('chokidar').FSWatcher[] = [] + +interface ChokidarFixture { + readonly label: string + readonly consumerManifest: string + readonly chokidarFiles: readonly string[] + readonly readdirpFiles: readonly string[] +} + +const CHOKIDAR_FIXTURES: readonly ChokidarFixture[] = [ + { + label: 'Chokidar 4 from settings and credentials', + consumerManifest: 'packages/settings/settings-file/package.json', + chokidarFiles: ['package.json', 'esm/package.json', 'esm/index.js', 'esm/handler.js'], + readdirpFiles: ['package.json', 'esm/package.json', 'esm/index.js'], + }, + { + label: 'Chokidar 5 from skill-filesystem', + consumerManifest: 'packages/skill/skill-filesystem/package.json', + chokidarFiles: ['package.json', 'index.js', 'handler.js'], + readdirpFiles: ['package.json', 'index.js'], + }, +] + +/** Copy one installed JavaScript package into the VFS exactly as the packer does. */ +function packageRoot(name: string, entry: string): string { + for (let directory = dirname(entry);;) { + const manifest = join(directory, 'package.json') + if (existsSync(manifest)) { + const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown } + if (parsed.name === name) return directory + } + const parent = dirname(directory) + if (parent === directory) throw new Error(`cannot locate package root for ${name}`) + directory = parent + } +} + +/** Copy the package files selected by the packer's import condition. */ +function mountPackage(name: string, directory: string, files: readonly string[]): void { + for (const file of files) { + const source = readFileSync(join(directory, file), 'utf8') + const path = `/dsh/node_modules/${name}/${file}` + vfs.seed(path, file.endsWith('.js') ? lowerModuleSource({ filename: path, source }).code : source) + } +} + +/** Load one consumer's exact Chokidar and readdirp versions through the Worker loader. */ +function loadChokidar(fixture: ChokidarFixture): typeof import('chokidar') { + const consumerManifest = join(process.cwd(), fixture.consumerManifest) + const chokidarEntry = createRequire(consumerManifest).resolve('chokidar') + const readdirpEntry = createRequire(chokidarEntry).resolve('readdirp') + mountPackage('chokidar', packageRoot('chokidar', chokidarEntry), fixture.chokidarFiles) + mountPackage('readdirp', packageRoot('readdirp', readdirpEntry), fixture.readdirpFiles) + const loader = new WorkerModuleLoader({ vfs, staticModules: createNodeBuiltins() }) + return loader.createRequire('/dsh/')('chokidar') as typeof import('chokidar') +} + +beforeEach(() => { + vfs = new MemoryVfs() + setActiveVfs(vfs) + vfs.mkdirSync(ROOT, { recursive: true }) +}) + +afterEach(async () => { + await Promise.all(openWatchers.splice(0).map(async (watcher) => { await watcher.close() })) +}) + +/** Await one emitter event while rejecting hangs deterministically. */ +function onceEvent(watcher: import('chokidar').FSWatcher, event: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { reject(new Error(`timed out waiting for chokidar ${event}`)) }, 2_000) + const emitter = watcher as unknown as { + once(name: string, listener: (...args: unknown[]) => void): void + } + emitter.once(event, (...args: unknown[]) => { + clearTimeout(timeout) + resolve(args[0] as T) + }) + }) +} + +/** Let watcher timers and promise-based stats reach a stable point. */ +async function delay(ms: number): Promise { + await new Promise((resolve) => { setTimeout(resolve, ms) }) +} + +/** Construct one tracked watcher with deterministic event normalization. */ +function watchPath(path: string, options: import('chokidar').ChokidarOptions = {}): import('chokidar').FSWatcher { + const watcher = chokidar.watch(path, { + ignoreInitial: true, + atomic: false, + awaitWriteFinish: false, + ...options, + }) + openWatchers.push(watcher) + return watcher +} + +describe.each(CHOKIDAR_FIXTURES)('$label running unchanged', (fixture) => { + beforeEach(() => { + chokidar = loadChokidar(fixture) + }) + + it('reaches ready and reports a file lifecycle through fs.watch', async () => { + const watcher = watchPath(ROOT, { depth: 1 }) + await onceEvent(watcher, 'ready') + + const directory = `${ROOT}/sample` + const file = `${directory}/SKILL.md` + const addDirectory = onceEvent(watcher, 'addDir') + const addFile = onceEvent(watcher, 'add') + vfs.mkdirSync(directory) + vfs.writeFileSync(file, '# sample\n') + await expect(addDirectory).resolves.toBe(directory) + await expect(addFile).resolves.toBe(file) + + const changed = onceEvent(watcher, 'change') + vfs.writeFileSync(file, '# changed\n') + await expect(changed).resolves.toBe(file) + + await new Promise((resolve) => { setTimeout(resolve, 10) }) + const removed = onceEvent(watcher, 'unlink') + vfs.rmSync(file) + await expect(removed).resolves.toBe(file) + }) + + it('watches a missing file through its existing parent', async () => { + const path = '/dsh/home/settings.yaml' + vfs.mkdirSync('/dsh/home', { recursive: true }) + const watcher = watchPath(path) + await onceEvent(watcher, 'ready') + + const added = onceEvent(watcher, 'add') + vfs.writeFileSync(path, 'theme: dark\n') + await expect(added).resolves.toBe(path) + + const removed = onceEvent(watcher, 'unlink') + vfs.rmSync(path) + await expect(removed).resolves.toBe(path) + }) + + it('discovers directory children through watchFile polling mode', async () => { + const watcher = watchPath(ROOT, { usePolling: true, interval: 5 }) + await onceEvent(watcher, 'ready') + const path = `${ROOT}/standalone.md` + const added = onceEvent(watcher, 'add') + vfs.writeFileSync(path, '# standalone\n') + await expect(added).resolves.toBe(path) + }) + + it('normalizes a short unlink/add replacement into one atomic change', async () => { + const path = `${ROOT}/atomic.md` + vfs.writeFileSync(path, 'before') + const watcher = watchPath(path, { atomic: 40 }) + await onceEvent(watcher, 'ready') + const events: string[] = [] + watcher.on('all', (event) => { events.push(event) }) + const changed = onceEvent(watcher, 'change') + vfs.rmSync(path) + await delay(5) + vfs.writeFileSync(path, 'after') + await expect(changed).resolves.toBe(path) + await delay(60) + expect(events).toEqual(['change']) + }) + + it('waits for a write burst to stabilize before publishing one add', async () => { + const path = `${ROOT}/settling.md` + const watcher = watchPath(ROOT, { + awaitWriteFinish: { stabilityThreshold: 30, pollInterval: 5 }, + }) + await onceEvent(watcher, 'ready') + const events: string[] = [] + watcher.on('all', (event) => { events.push(event) }) + const added = onceEvent(watcher, 'add') + vfs.writeFileSync(path, 'a') + await delay(10) + vfs.appendFileSync(path, 'b') + await delay(10) + vfs.appendFileSync(path, 'c') + await expect(added).resolves.toBe(path) + expect(events).toEqual(['add']) + }) + + it('emits nothing after close has reached quiescence', async () => { + const watcher = watchPath(ROOT) + const events: string[] = [] + watcher.on('all', (event) => { events.push(event) }) + await onceEvent(watcher, 'ready') + await watcher.close() + vfs.writeFileSync(`${ROOT}/after.md`, '# after\n') + await Promise.resolve() + expect(events).toEqual([]) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts new file mode 100644 index 0000000000..e095d2b6f2 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -0,0 +1,507 @@ +/** Node differential checks for the Worker filesystem watcher and stream faces. */ +import { + createReadStream as createNodeReadStream, + createWriteStream as createNodeWriteStream, + mkdtempSync, + readFileSync, + rmSync, + unwatchFile as unwatchNodeFile, + watchFile as watchNodeFile, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryVfs } from '../../src/storage/memory.ts' +import { setActiveVfs } from '../../src/storage/active.ts' +import * as workerFs from '../../src/node/builtin_modules/implemented/fs.ts' +import * as workerFsp from '../../src/node/builtin_modules/implemented/fs/promises.ts' +import * as workerStream from '../../src/node/builtin_modules/implemented/stream.ts' + +const VFS_ROOT = '/dsh/watch-stream' +const nativeRoots: string[] = [] +let vfs: MemoryVfs + +beforeEach(() => { + vfs = new MemoryVfs() + setActiveVfs(vfs) + vfs.mkdirSync(VFS_ROOT, { recursive: true }) +}) + +afterEach(() => { + for (const root of nativeRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +/** Await the next callback value with a bounded failure instead of an open watcher. */ +function nextValue(install: (resolve: (value: T) => void) => void): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { reject(new Error('timed out waiting for filesystem event')) }, 2_000) + install((value) => { + clearTimeout(timeout) + resolve(value) + }) + }) +} + +interface ReadableFileStream { + readonly bytesRead: number + on(event: string, listener: (...args: unknown[]) => void): ReadableFileStream +} + +/** Collect byte chunks and lifecycle events from one read stream implementation. */ +async function readScenario(create: () => ReadableFileStream): Promise<{ + chunks: string[] + events: string[] + bytesRead: number +}> { + const stream = create() + const chunks: string[] = [] + const events: string[] = [] + stream.on('open', () => { events.push('open') }) + stream.on('ready', () => { events.push('ready') }) + stream.on('data', (chunk: unknown) => { + events.push('data') + chunks.push(Buffer.from(chunk as Uint8Array).toString('utf8')) + }) + stream.on('end', () => { events.push('end') }) + await new Promise((resolve, reject) => { + stream.on('error', reject) + stream.on('close', () => { + events.push('close') + resolve() + }) + }) + return { chunks, events, bytesRead: stream.bytesRead } +} + +interface WritableFileStream { + readonly bytesWritten: number + on(event: string, listener: (...args: unknown[]) => void): WritableFileStream + write(chunk: string): boolean + end(chunk?: string): void +} + +/** Write the same chunks and record backpressure plus lifecycle ordering. */ +async function writeScenario(create: () => WritableFileStream): Promise<{ + writes: boolean[] + events: string[] + bytesWritten: number +}> { + const stream = create() + const events: string[] = [] + for (const event of ['open', 'ready', 'drain', 'finish'] as const) { + stream.on(event, () => { events.push(event) }) + } + const writes = [stream.write('ab'), stream.write('cd')] + stream.end('ef') + await new Promise((resolve, reject) => { + stream.on('error', reject) + stream.on('close', () => { + events.push('close') + resolve() + }) + }) + return { writes, events, bytesWritten: stream.bytesWritten } +} + +describe('file streams', () => { + it('matches Node chunking, inclusive ranges, and read lifecycle ordering', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'input.txt') + const workerPath = `${VFS_ROOT}/input.txt` + writeFileSync(nativePath, '0123456789') + vfs.writeFileSync(workerPath, '0123456789') + + const native = await readScenario(() => createNodeReadStream(nativePath, { start: 2, end: 7, highWaterMark: 2 })) + const worker = await readScenario(() => workerFs.createReadStream(workerPath, { start: 2, end: 7, highWaterMark: 2 })) + expect(worker).toEqual(native) + }) + + it('matches Node write backpressure, lifecycle ordering, and byte accounting', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'output.txt') + const workerPath = `${VFS_ROOT}/output.txt` + + const native = await writeScenario(() => createNodeWriteStream(nativePath, { highWaterMark: 2 })) + const worker = await writeScenario(() => workerFs.createWriteStream(workerPath, { highWaterMark: 2 })) + expect(worker).toEqual(native) + expect(workerFs.readFileSync(workerPath, 'utf8')).toBe('abcdef') + }) + + it('uses the maintained stream implementation for backpressure and async iteration', async () => { + const values: string[] = [] + for await (const value of workerStream.Readable.from(['one', 'two'])) values.push(String(value)) + expect(values).toEqual(['one', 'two']) + expect(typeof workerStream.pipeline).toBe('function') + expect(typeof workerStream.finished).toBe('function') + expect(workerStream.getDefaultHighWaterMark(false)).toBe(64 * 1024) + expect(workerStream.default._isArrayBufferView(new Uint8Array())).toBe(true) + }) + + it('matches Node file-stream defaults and abort error identity', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'input.txt') + const workerPath = `${VFS_ROOT}/input.txt` + writeFileSync(nativePath, 'content') + vfs.writeFileSync(workerPath, 'content') + const nativeRead = createNodeReadStream(nativePath) + const nativeWrite = createNodeWriteStream(join(nativeRoot, 'output.txt')) + const workerRead = workerFs.createReadStream(workerPath) + const workerWrite = workerFs.createWriteStream(`${VFS_ROOT}/output.txt`) + expect([workerRead.readableHighWaterMark, workerWrite.writableHighWaterMark]).toEqual([ + nativeRead.readableHighWaterMark, + nativeWrite.writableHighWaterMark, + ]) + interface CloseableStream { + once(event: string, listener: (...args: unknown[]) => void): unknown + destroy(): unknown + } + const streams = [nativeRead, nativeWrite, workerRead, workerWrite] as unknown as CloseableStream[] + const closed = streams.map(stream => new Promise((resolve) => { + stream.once('error', () => {}) + stream.once('close', () => { resolve() }) + })) + for (const stream of streams) stream.destroy() + await Promise.all(closed) + + const controller = new AbortController() + controller.abort(new Error('stop')) + const aborted = workerFs.createReadStream(workerPath, { signal: controller.signal }) + const error = await nextValue((resolve) => { aborted.once('error', resolve) }) + expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + }) + + it('matches Node positional overwrite and missing-file failure', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'position.txt') + const workerPath = `${VFS_ROOT}/position.txt` + writeFileSync(nativePath, 'abcdef') + vfs.writeFileSync(workerPath, 'abcdef') + + const writeAt = async (stream: WritableFileStream): Promise => { + stream.end('XY') + await new Promise((resolve) => { stream.on('close', () => { resolve() }) }) + } + await writeAt(createNodeWriteStream(nativePath, { flags: 'r+', start: 2 })) + await writeAt(workerFs.createWriteStream(workerPath, { flags: 'r+', start: 2 })) + expect(workerFs.readFileSync(workerPath, 'utf8')).toBe(readFileSync(nativePath, 'utf8')) + + const missing = workerFs.createReadStream(`${VFS_ROOT}/missing.txt`) + const events: string[] = [] + missing.on('error', () => { events.push('error') }) + await new Promise((resolve) => { + missing.on('close', () => { + events.push('close') + resolve() + }) + }) + expect(events).toEqual(['error', 'close']) + }) +}) + +interface StatTransition { + currentExists: boolean + previousExists: boolean + currentSize: number + previousSize: number + currentOtherKinds: boolean[] +} + +/** Observe missing, creation, rewrite, and deletion through one watchFile implementation. */ +async function watchFileScenario( + path: string, + watchFile: typeof watchNodeFile, + unwatchFile: typeof unwatchNodeFile, + write: (text: string) => void, + remove: () => void, +): Promise { + const waiting: Array<(value: StatTransition) => void> = [] + const queued: StatTransition[] = [] + const listener = (current: import('node:fs').Stats, previous: import('node:fs').Stats): void => { + const transition = { + currentExists: current.isFile(), + previousExists: previous.isFile(), + currentSize: current.size, + previousSize: previous.size, + currentOtherKinds: [ + current.isDirectory(), current.isSymbolicLink(), current.isFIFO(), + current.isSocket(), current.isBlockDevice(), current.isCharacterDevice(), + ], + } + const resolve = waiting.shift() + if (resolve === undefined) queued.push(transition) + else resolve(transition) + } + const next = async (): Promise => { + const queuedValue = queued.shift() + if (queuedValue !== undefined) return queuedValue + return await nextValue((resolve) => { waiting.push(resolve) }) + } + watchFile(path, { interval: 10, persistent: false }, listener) + try { + const missing = await next() + write('a') + const created = await next() + write('longer') + const changed = await next() + remove() + const removed = await next() + return [missing, created, changed, removed] + } finally { + unwatchFile(path, listener) + } +} + +describe('watchers', () => { + it('matches Node watchFile state transitions for a missing and recreated file', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-watch-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'watched.txt') + const workerPath = `${VFS_ROOT}/watched.txt` + const native = await watchFileScenario( + nativePath, + watchNodeFile, + unwatchNodeFile, + (text) => { writeFileSync(nativePath, text) }, + () => { rmSync(nativePath) }, + ) + const worker = await watchFileScenario( + workerPath, + workerFs.watchFile as unknown as typeof watchNodeFile, + workerFs.unwatchFile as unknown as typeof unwatchNodeFile, + (text) => { vfs.writeFileSync(workerPath, text) }, + () => { vfs.rmSync(workerPath) }, + ) + expect(worker).toEqual(native) + }) + + it('shares one StatWatcher and removes only the named listener', async () => { + const path = `${VFS_ROOT}/shared.txt` + vfs.writeFileSync(path, 'a') + const firstEvents: number[] = [] + const secondEvents: number[] = [] + const first = (): void => { firstEvents.push(1) } + const second = (): void => { secondEvents.push(1) } + const firstWatcher = workerFs.watchFile(path, { interval: 1, persistent: false }, first) + const secondWatcher = workerFs.watchFile(path, { interval: 1, persistent: false }, second) + expect(secondWatcher).toBe(firstWatcher) + workerFs.unwatchFile(path, first) + vfs.writeFileSync(path, 'bb') + await nextValue((resolve) => { + const poll = setInterval(() => { + if (secondEvents.length === 0) return + clearInterval(poll) + resolve(undefined) + }, 1) + }) + expect(firstEvents).toEqual([]) + expect(secondEvents).toEqual([1]) + workerFs.unwatchFile(path) + }) + + it('reports direct and recursive names, then reaches quiescence on close', async () => { + const root = `${VFS_ROOT}/tree` + vfs.mkdirSync(`${root}/nested`, { recursive: true }) + const directEvents: Array<[string, string]> = [] + const recursiveEvents: Array<[string, string]> = [] + const direct = workerFs.watch(root, (_event, _filename) => {}) + direct.on('change', (event, filename) => { directEvents.push([String(event), String(filename)]) }) + const recursive = workerFs.watch(root, { recursive: true }, (event, filename) => { + recursiveEvents.push([event, String(filename)]) + }) + vfs.writeFileSync(`${root}/top.txt`, 'top') + vfs.writeFileSync(`${root}/nested/deep.txt`, 'deep') + await Promise.resolve() + expect(directEvents).toEqual([['rename', 'top.txt']]) + expect(recursiveEvents).toEqual([ + ['rename', 'top.txt'], + ['rename', 'nested/deep.txt'], + ]) + direct.close() + recursive.close() + vfs.writeFileSync(`${root}/after.txt`, 'after') + await Promise.resolve() + expect(directEvents).toHaveLength(1) + expect(recursiveEvents).toHaveLength(2) + }) + + it('supports Buffer filenames, file targets, abort closure, and ref state', async () => { + const path = `${VFS_ROOT}/encoded.txt` + vfs.writeFileSync(path, 'before') + const controller = new AbortController() + const event = nextValue<[string, Buffer]>((resolve) => { + const watcher = workerFs.watch( + new TextEncoder().encode(path), + { encoding: 'buffer', persistent: false, signal: controller.signal }, + (eventType, filename) => { resolve([eventType, filename as Buffer]) }, + ) + expect(watcher.hasRef()).toBe(false) + expect(watcher.ref().hasRef()).toBe(true) + expect(watcher.unref().hasRef()).toBe(false) + }) + vfs.writeFileSync(path, 'after') + const [eventType, filename] = await event + expect(eventType).toBe('change') + expect(Buffer.isBuffer(filename)).toBe(true) + expect(filename.toString()).toBe('encoded.txt') + + const watcher = workerFs.watch(path, { signal: controller.signal }) + let closes = 0 + const closed = nextValue((resolve) => { + watcher.on('close', () => { + closes += 1 + resolve(undefined) + }) + }) + controller.abort(new Error('stop')) + await closed + watcher.close() + await Promise.resolve() + expect(closes).toBe(1) + }) + + it('supports the string encoding overload and suppresses queued delivery after close', async () => { + const encoded = nextValue((resolve) => { + const watcher = workerFs.watch(VFS_ROOT, 'buffer', (_eventType, filename) => { + watcher.close() + resolve(filename as Buffer) + }) + }) + vfs.writeFileSync(`${VFS_ROOT}/buffer-name.txt`, 'x') + await expect(encoded).resolves.toEqual(Buffer.from('buffer-name.txt')) + + let calls = 0 + const closed = workerFs.watch(VFS_ROOT, () => { calls += 1 }) + vfs.writeFileSync(`${VFS_ROOT}/queued.txt`, 'x') + closed.close() + await Promise.resolve() + expect(calls).toBe(0) + }) + + it('reports removal of an ancestor to a watched file', async () => { + const directory = `${VFS_ROOT}/removed-parent` + const path = `${directory}/file.txt` + vfs.mkdirSync(directory) + vfs.writeFileSync(path, 'x') + const event = nextValue<[string, string]>((resolve) => { + const watcher = workerFs.watch(path, (eventType, filename) => { + watcher.close() + resolve([eventType, String(filename)]) + }) + }) + vfs.rmSync(directory, { recursive: true }) + await expect(event).resolves.toEqual(['rename', 'file.txt']) + }) + + it('rejects an already-aborted callback watcher without retaining a subscription', () => { + const controller = new AbortController() + const reason = new Error('already stopped') + controller.abort(reason) + try { + workerFs.watch(VFS_ROOT, { signal: controller.signal }) + throw new Error('watch unexpectedly opened') + } catch (error) { + expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) + } + expect(() => { vfs.writeFileSync(`${VFS_ROOT}/after-abort.txt`, 'x') }).not.toThrow() + }) + + it('reports an atomic replacement destination as rename even when it existed', async () => { + const target = `${VFS_ROOT}/target.txt` + const replacement = `${VFS_ROOT}/replacement.txt` + vfs.writeFileSync(target, 'old') + vfs.writeFileSync(replacement, 'new') + const event = nextValue<[string, string]>((resolve) => { + const watcher = workerFs.watch(VFS_ROOT, (eventType, filename) => { + if (String(filename) !== 'target.txt') return + watcher.close() + resolve([eventType, String(filename)]) + }) + }) + vfs.renameSync(replacement, target) + await expect(event).resolves.toEqual(['rename', 'target.txt']) + }) + + it('supports BigInt watchFile state, default options, and idempotent stop', async () => { + const path = `${VFS_ROOT}/bigint.txt` + const states = nextValue<[bigint, bigint]>((resolve) => { + const watcher = workerFs.watchFile(new URL(`file://${path}`), { bigint: true, interval: 1 }, (current, previous) => { + resolve([current.size as bigint, previous.size as bigint]) + }) + expect(watcher.hasRef()).toBe(true) + expect(watcher.unref().hasRef()).toBe(false) + expect(watcher.ref().hasRef()).toBe(true) + }) + vfs.writeFileSync(path, 'big') + await expect(states).resolves.toEqual([3n, 0n]) + workerFs.unwatchFile(path) + workerFs.unwatchFile(path) + + vfs.writeFileSync(`${VFS_ROOT}/default.txt`, 'x') + const listener = (): void => {} + const defaultWatcher = workerFs.watchFile(`${VFS_ROOT}/default.txt`, listener) + expect(defaultWatcher.hasRef()).toBe(true) + defaultWatcher.close() + defaultWatcher.close() + expect(() => workerFs.watchFile(`${VFS_ROOT}/default.txt`, {})).toThrow(/listener/) + + let cancelledCalls = 0 + const cancelled = workerFs.watchFile(`${VFS_ROOT}/never-created`, { interval: 1 }, () => { cancelledCalls += 1 }) + cancelled.close() + cancelled.close() + await new Promise((resolve) => { setTimeout(resolve, 5) }) + expect(cancelledCalls).toBe(0) + }) + + it('propagates non-absence stat failures from watchFile', () => { + const failure = Object.assign(new Error('denied'), { code: 'EACCES' }) + vi.spyOn(vfs, 'statSync').mockImplementationOnce(() => { throw failure }) + expect(() => workerFs.watchFile(`${VFS_ROOT}/denied`, () => {})).toThrow(failure) + }) + + it('exposes promise watch as an abortable async iterator', async () => { + const controller = new AbortController() + const iterator = workerFsp.watch(VFS_ROOT, { signal: controller.signal })[Symbol.asyncIterator]() + const event = iterator.next() + vfs.writeFileSync(`${VFS_ROOT}/async.txt`, 'x') + await expect(event).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'async.txt' } }) + controller.abort() + await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + }) + + it('lets promise-watch return interrupt a pending next call', async () => { + const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() + const pending = iterator.next() + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + vfs.writeFileSync(`${VFS_ROOT}/after-return.txt`, 'x') + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('propagates promise-watch startup and throw failures', async () => { + const missing = workerFsp.watch(`${VFS_ROOT}/missing`)[Symbol.asyncIterator]() + await expect(missing.next()).rejects.toMatchObject({ code: 'ENOENT' }) + + const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() + const reason = { reason: 'caller stopped iteration' } + if (iterator.throw === undefined) throw new Error('watch iterator has no throw method') + await expect(iterator.throw(reason)).rejects.toBe(reason) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('queues promise-watch events when no next call is waiting', async () => { + const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() + const first = iterator.next() + vfs.writeFileSync(`${VFS_ROOT}/one.txt`, 'one') + vfs.writeFileSync(`${VFS_ROOT}/two.txt`, 'two') + await expect(first).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'one.txt' } }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'two.txt' } }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index 400e01ee92..ce9e6fd916 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts @@ -12,9 +12,17 @@ import { MemoryVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/s import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts' import * as fs from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs.ts' import * as fsp from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts' -import type { VfsBigIntStats, VfsStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts' +import type { VfsBigIntStats, VfsMutationSink, VfsStats } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types.ts' -const vfs = new MemoryVfs() +let flushes = 0 +const sink: VfsMutationSink = { + record: () => {}, + flush: () => { + flushes += 1 + return Promise.resolve() + }, +} +const vfs = new MemoryVfs({ sink }) setActiveVfs(vfs) // Identity precondition: the bridge must read this exact mounted VFS; successful @@ -76,9 +84,6 @@ throws('readFileSync missing', () => fs.readFileSync('/dsh/missing'), 'ENOENT') throws('statSync missing', () => fs.statSync('/dsh/missing'), 'ENOENT') throws('accessSync missing', () =>{ fs.accessSync('/dsh/missing') }, 'ENOENT') throws('readdirSync missing', () => fs.readdirSync('/dsh/missing'), 'ENOENT') -throws('watchFile is loud', () => fs.watchFile('/dsh/config/cordis.yml'), 'not implemented') -throws('createReadStream is loud', () => fs.createReadStream('/dsh/config/cordis.yml'), 'not implemented') - const appendFd = fs.openSync('/dsh/log.jsonl', 'a') fs.writeSync(appendFd, '{"a":1}\n') fs.writeSync(appendFd, '{"a":2}\n') @@ -112,6 +117,7 @@ const appendHandle = await fsp.open('/dsh/log-handle.jsonl', 'a') check('append handle sees the existing size', (await appendHandle.stat()).size, 7) await appendHandle.writeFile('batch-1\n') await appendHandle.sync() +check('handle.sync flushes the active VFS', flushes, 1) await appendHandle.close() const secondHandle = await fsp.open('/dsh/log-handle.jsonl', 'a') await secondHandle.writeFile('batch-2\n') diff --git a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts index ef52920292..35d6f32460 100644 --- a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts @@ -16,17 +16,14 @@ import { notAvailableError, notImplementedFail } from '../../src/node/notImpleme import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts' import * as net from '../../src/node/builtin_modules/mock/net.ts' import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts' -import * as stream from '../../src/node/builtin_modules/mock/stream.ts' +import * as stream from '../../src/node/builtin_modules/implemented/stream.ts' import * as vm from '../../src/node/builtin_modules/mock/vm.ts' import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts' -import * as chokidar from '../../src/node/external_packages/chokidar.ts' -import * as landlock from '../../src/node/external_packages/node-addon-landlock-run.ts' import * as nodePty from '../../src/node/external_packages/node-pty.ts' import * as piAi from '../../src/node/external_packages/pi-ai.ts' import * as ripgrep from '../../src/node/external_packages/ripgrep.ts' import * as ws from '../../src/node/external_packages/ws.ts' import { REPLACED_EXTERNAL_PACKAGES } from '../../src/node/external_packages/replaced-externals.ts' -import * as fs from '../../src/node/builtin_modules/implemented/fs.ts' import * as os from '../../src/node/builtin_modules/implemented/os.ts' import * as perfHooks from '../../src/node/builtin_modules/implemented/perf_hooks.ts' import { DSH_HOME, DSH_TMP } from '../../src/storage/paths.ts' @@ -43,9 +40,7 @@ const CALLED: [string, Record, readonly string[]][] = [ // The rest of `node:child_process` runs commands (see child-process.spec.ts); // these three need a real process, so they stay refusals. ['node:child_process', childProcess, ['execFileSync', 'execSync', 'fork']], - ['node:stream', stream, ['Readable', 'Writable', 'Duplex', 'Transform', 'PassThrough', 'pipeline', 'finished']], ['node-pty', nodePty, ['spawn', 'open']], - ['@deepseek-ai/node-addon-landlock-run', landlock, ['probe']], ['@deepseek-ai/pi-ai', piAi, [ 'createProvider', 'createModels', 'openAICompletionsApi', 'openAIResponsesApi', 'anthropicMessagesApi', 'isContextOverflow', 'getSupportedThinkingLevels', @@ -95,7 +90,7 @@ describe('not-implemented stubs', () => { } it('keeps the CommonJS interop marker and a default export on every replaced module', () => { - for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, chokidar, ws, nodePty, piAi, os, perfHooks]) { + for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, ws, nodePty, piAi, os, perfHooks]) { const holder = namespace as { __esModule?: unknown; default?: unknown } expect(holder.__esModule).toBe(true) expect(holder.default).toBeDefined() @@ -104,19 +99,6 @@ describe('not-implemented stubs', () => { }) describe('constructible-but-inert fakes', () => { - // These two are constructed in `[Service.init]` bodies and field initializers, - // so construction must succeed; only the members that would move bytes refuse. - it('chokidar watches nothing and says so by never emitting', async () => { - const watcher = chokidar.watch() - expect(watcher).toBeInstanceOf(chokidar.FSWatcher) - expect(watcher.on()).toBe(watcher) - expect(watcher.once()).toBe(watcher) - expect(watcher.add()).toBe(watcher) - expect(watcher.unwatch()).toBe(watcher) - expect(watcher.getWatched()).toEqual({}) - await expect(watcher.close()).resolves.toBeUndefined() - }) - it('a ws server constructs, accepts listeners, and refuses to carry an upgrade', () => { quiet() expect(ws.Server).toBe(ws.WebSocketServer) @@ -133,16 +115,14 @@ describe('constructible-but-inert fakes', () => { describe('replaced external packages', () => { it('lists the packages the loader serves from the bundle', () => { - expect(REPLACED_EXTERNAL_PACKAGES).toContain('chokidar') + expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('chokidar') + expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-landlock-run') expect(REPLACED_EXTERNAL_PACKAGES).toContain('ws') }) it('answers the values callers read without invoking anything', () => { - // The ripgrep binary path and the landlock launcher are read as data by - // consumers that then fail on their own terms. + // The ripgrep binary path is read as data by its consumer. expect(typeof ripgrep.rgPath).toBe('string') - expect(typeof landlock.LAUNCHER_BIN).toBe('string') - expect(typeof landlock.LAUNCHER_FAILURE_EXIT).toBe('number') }) }) @@ -190,17 +170,3 @@ describe('node:perf_hooks', () => { expect(perfHooks.performance.now()).toBeGreaterThan(0) }) }) - -describe('watching', () => { - // Watching stays a loud refusal because `skill-filesystem` AWAITS watcher - // progress rather than merely registering a listener; an inert watcher left - // its discovery hanging. `fs.ts` records the experiment and the mechanism. - it('refuses, naming the member, so an awaiting caller fails fast', () => { - quiet() - expect(() => fs.watchFile('/dsh/config/cordis.yml')).toThrow(/watchFile is not implemented in the worker host/) - }) - - it('accepts the unconditional teardown call, since nothing was watched', () => { - expect(() => { fs.unwatchFile() }).not.toThrow() - }) -}) diff --git a/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts b/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts new file mode 100644 index 0000000000..599608d598 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts @@ -0,0 +1,98 @@ +/** The unchanged sandbox-local → bash-sandbox → subprocess stack over the Worker Node layer. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSandboxProvider from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { MemoryVfs } from '../../src/storage/memory.ts' +import { setActiveVfs } from '../../src/storage/active.ts' +import { processAlive, signalProcess } from '../../src/node/process-table.ts' + +vi.mock('node:child_process', async () => await import('../../src/node/builtin_modules/implemented/child_process.ts')) + +const WORKSPACE = '/dsh/workspace' +const OUTSIDE = '/dsh/home' +let vfs: MemoryVfs +const contexts: Context[] = [] + +beforeEach(() => { + vfs = new MemoryVfs() + setActiveVfs(vfs) + vfs.mkdirSync(WORKSPACE, { recursive: true }) + vfs.mkdirSync(OUTSIDE, { recursive: true }) + vfs.mkdirSync('/dsh/tmp', { recursive: true }) + vi.spyOn(process, 'kill').mockImplementation((pid: number, signal?: string | number): true => { + if (signal === 0) { + if (processAlive(pid)) return true + const error = new Error('kill ESRCH') as NodeJS.ErrnoException + error.code = 'ESRCH' + throw error + } + signalProcess(pid, (signal ?? 'SIGTERM') as NodeJS.Signals) + return true + }) +}) + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(async (ctx) => { await ctx.fiber.dispose() })) + vi.restoreAllMocks() +}) + +/** Boot the production providers while only their platform primitives are replaced. */ +async function setup(mode: 'read-only' | 'workspace-write' | 'danger-full-access'): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LocalSandboxProvider) + await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: WORKSPACE }) + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(SandboxBashExecutor, { cwd: WORKSPACE }) + return ctx.shell as SandboxBashExecutor +} + +describe('Worker Landlock through the production sandbox stack', () => { + it('allows workspace and temp writes while classifying an outside write as denied', async () => { + const bash = await setup('workspace-write') + const allowed = await bash.run(bash.resolve({ + command: `echo workspace > ${WORKSPACE}/allowed.txt; echo temp > /tmp/allowed.txt`, + })) + expect(allowed.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n') + expect(vfs.readFileSync('/dsh/tmp/allowed.txt', 'utf8')).toBe('temp\n') + + const denied = await bash.run(bash.resolve({ command: `echo denied > ${OUTSIDE}/denied.txt` })) + expect(denied.exitCode).toBe(1) + expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' }) + expect(vfs.existsSync(`${OUTSIDE}/denied.txt`)).toBe(false) + }) + + it('keeps read-only confined and danger-full-access unwrapped', async () => { + const readOnly = await setup('read-only') + const strict = await readOnly.run(readOnly.resolve({ + command: `echo discarded > /dev/null; echo denied > ${WORKSPACE}/strict.txt`, + })) + expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false) + + const unrestricted = await setup('danger-full-access') + const result = await unrestricted.run(unrestricted.resolve({ command: `echo allowed > ${OUTSIDE}/full.txt` })) + expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) + expect(vfs.readFileSync(`${OUTSIDE}/full.txt`, 'utf8')).toBe('allowed\n') + }) + + it('does not leak a concurrent command policy into another process', async () => { + const bash = await setup('read-only') + const strict = bash.run(bash.resolve({ + command: `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`, + })) + const writable = bash.run(bash.resolve({ + command: `echo allowed > ${WORKSPACE}/writable.txt`, + sandboxPolicy: { mode: 'workspace-write', workspaceRoot: WORKSPACE }, + })) + const [strictResult, writableResult] = await Promise.all([strict, writable]) + expect(strictResult.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + expect(writableResult.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false) + expect(vfs.readFileSync(`${WORKSPACE}/writable.txt`, 'utf8')).toBe('allowed\n') + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 1068cfdf21..8278e6c153 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -1,6 +1,7 @@ /** - * The identity, timestamp, and link guarantees MemoryVfs owes its consumers, - * asserted on the filesystem directly rather than through the `node:fs` bridge. + * The identity, timestamp, link, mutation, and durability-sink guarantees + * MemoryVfs owes its consumers, asserted directly rather than through the + * `node:fs` bridge. * * `dsh-fs-local` builds a version token from `dev:ino:size:mtimeNs:ctimeNs` and * refuses a write whose token moved since it read. Two properties carry that: @@ -11,7 +12,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryVfs } from '../../src/storage/memory.ts' -import type { VfsBigIntStats, VfsStats } from '../../src/storage/types.ts' +import type { VfsBigIntStats, VfsMutation, VfsMutationSink, VfsStats } from '../../src/storage/types.ts' const identity = (vfs: MemoryVfs, path: string): bigint => (vfs.statSync(path, { bigint: true }) as VfsBigIntStats).ino @@ -55,6 +56,16 @@ describe('entry identity', () => { }) describe('modification time', () => { + it('hydrates explicit metadata without confusing timestamps with permission bits', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/restored', 'value', { mode: 0o600, mtimeMs: 1_600_000_000_000 }) + vfs.seedDirectory('/dsh/restored-directory', { mode: 0o700, mtimeMs: 1_600_000_000_001 }) + const stats = vfs.statSync('/dsh/restored') as VfsStats + const directory = vfs.statSync('/dsh/restored-directory') as VfsStats + expect([stats.mode & 0o777, stats.mtimeMs]).toEqual([0o600, 1_600_000_000_000]) + expect([directory.mode & 0o777, directory.mtimeMs]).toEqual([0o700, 1_600_000_000_001]) + }) + it('advances on every write even while the clock stands still', () => { vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) const vfs = new MemoryVfs() @@ -80,6 +91,108 @@ describe('modification time', () => { vfs.writeFileSync('/dsh/log.jsonl', 'second\n') expect(modified(vfs, '/dsh/log.jsonl')).toBe(1_700_000_005_000) }) + + it('advances a directory only when its immediate entry set changes', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + const vfs = new MemoryVfs() + vfs.seedDirectory('/dsh/workspace') + const empty = modified(vfs, '/dsh/workspace') + vfs.writeFileSync('/dsh/workspace/file.txt', 'one') + const created = modified(vfs, '/dsh/workspace') + vfs.writeFileSync('/dsh/workspace/file.txt', 'two') + const rewritten = modified(vfs, '/dsh/workspace') + vfs.rmSync('/dsh/workspace/file.txt') + const removed = modified(vfs, '/dsh/workspace') + expect([created > empty, rewritten === created, removed > rewritten]).toEqual([true, true, true]) + }) +}) + +describe('mutation publication', () => { + it('publishes only committed runtime changes and keeps image seeding silent', () => { + const vfs = new MemoryVfs() + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + vfs.seed('/dsh/seeded.txt', 'seeded') + expect(mutations).toEqual([]) + vfs.writeFileSync('/dsh/seeded.txt', 'changed') + vfs.mkdirSync('/dsh/created') + vfs.chmodSync('/dsh/created', 0o700) + vfs.renameSync('/dsh/seeded.txt', '/dsh/renamed.txt') + vfs.rmSync('/dsh/created', { recursive: true }) + expect(mutations.map(mutation => ({ + kind: mutation.kind, + path: mutation.path, + ...mutation.kind === 'write' ? { entryChanged: mutation.entryChanged } : {}, + ...mutation.kind === 'chmod' ? { mode: mutation.mode } : {}, + }))).toEqual([ + { kind: 'write', path: '/dsh/seeded.txt', entryChanged: false }, + { kind: 'mkdir', path: '/dsh/created' }, + { kind: 'chmod', path: '/dsh/created', mode: 0o700 }, + { kind: 'remove', path: '/dsh/seeded.txt' }, + { kind: 'write', path: '/dsh/renamed.txt', entryChanged: true }, + { kind: 'remove', path: '/dsh/created' }, + ]) + const renamed = mutations[4] + expect(renamed?.kind === 'write' && new TextDecoder().decode(renamed.bytes)).toBe('changed') + expect(() => { vfs.writeFileSync('/missing/file', 'no') }).toThrow(/ENOENT/) + expect(mutations).toHaveLength(6) + }) + + it('contains a faulty observer and lets disposal stop later notifications', () => { + const vfs = new MemoryVfs() + vfs.seedDirectory('/dsh') + const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) + const first = vfs.subscribe(() => { throw new Error('observer failed') }) + const seen: string[] = [] + const second = vfs.subscribe((mutation) => { seen.push(mutation.path) }) + vfs.writeFileSync('/dsh/one', '1') + first() + second() + vfs.writeFileSync('/dsh/two', '2') + expect(seen).toEqual(['/dsh/one']) + expect(reported).toHaveBeenCalledOnce() + }) + + it('feeds the same complete mutations to a durable sink and live subscribers', async () => { + const recorded: VfsMutation[] = [] + let flushes = 0 + const sink: VfsMutationSink = { + record: (mutation) => { recorded.push(mutation) }, + flush: async () => { flushes += 1 }, + } + const vfs = new MemoryVfs({ sink }) + vfs.seedDirectory('/dsh') + const observed: VfsMutation[] = [] + vfs.subscribe((mutation) => { observed.push(mutation) }) + vfs.writeFileSync('/dsh/log', 'a') + vfs.appendFileSync('/dsh/log', 'bc') + await vfs.flush() + expect(observed).toEqual(recorded) + expect(observed[0]).toBe(recorded[0]) + expect(recorded[0]).toMatchObject({ kind: 'write', path: '/dsh/log', mode: 0o644, entryChanged: true }) + expect(recorded[1]).toMatchObject({ kind: 'write', path: '/dsh/log', mode: 0o644, entryChanged: false, appendedFrom: 1 }) + expect(recorded[1]?.kind === 'write' && new TextDecoder().decode(recorded[1].bytes)).toBe('abc') + expect(flushes).toBe(1) + }) + + it('decomposes a directory rename into replayable destination state', () => { + const recorded: VfsMutation[] = [] + const vfs = new MemoryVfs({ + sink: { record: (mutation) => { recorded.push(mutation) }, flush: () => Promise.resolve() }, + }) + vfs.seedDirectory('/dsh/staging/nested', { mode: 0o700 }) + vfs.seed('/dsh/staging/nested/file', 'value', { mode: 0o600 }) + vfs.renameSync('/dsh/staging', '/dsh/published') + + expect(recorded.map(mutation => [mutation.kind, mutation.path])).toEqual([ + ['remove', '/dsh/staging'], + ['mkdir', '/dsh/published'], + ['mkdir', '/dsh/published/nested'], + ['write', '/dsh/published/nested/file'], + ]) + expect(recorded[3]).toMatchObject({ kind: 'write', mode: 0o600, entryChanged: true }) + expect(recorded[3]?.kind === 'write' && new TextDecoder().decode(recorded[3].bytes)).toBe('value') + }) }) describe('hard links', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5f3e827dd..5c25bbf2ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4814,6 +4814,9 @@ importers: picomatch: specifier: ^4.0.4 version: 4.0.4 + readable-stream: + specifier: ^4.7.0 + version: 4.7.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4824,6 +4827,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../../api/gateway + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../shell/bash-sandbox '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules @@ -4836,12 +4842,27 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:^ + version: link:../../../native/landlock-run/packages/entry '@types/picomatch': specifier: ^3.0.2 version: 3.0.2 + '@types/readable-stream': + specifier: ^4.0.24 + version: 4.0.24 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 packages/extensions/cordis-client-runner: devDependencies: @@ -12641,6 +12662,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/readable-stream@4.0.24': + resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -12892,6 +12916,10 @@ packages: resolution: {integrity: sha512-WoxUM/Be4hfsX06FxsvpGgfYqwgivMV7/Ol7aFuSfSmY6rRaiju4QxOEe9RUS0iYcSHWl5i9AhB1cMoE0p+XiA==} engines: {node: '>=18.12.0'} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -13583,9 +13611,17 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -14818,6 +14854,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -14875,6 +14915,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -15122,6 +15166,9 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -17914,6 +17961,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/readable-stream@4.0.24': + dependencies: + '@types/node': 22.20.0 + '@types/retry@0.12.0': {} '@types/spdx-expression-parse@4.0.0': {} @@ -18191,6 +18242,10 @@ snapshots: js-yaml: 4.3.1 tslib: 2.8.1 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -18951,8 +19006,12 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + events@3.3.0: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -20426,6 +20485,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + property-information@7.2.0: {} protobufjs@7.6.4: @@ -20498,6 +20559,14 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -20840,6 +20909,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 From 181a0e18ef41f2b146c4d604a3d38988db9c8473 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:09:20 +0800 Subject: [PATCH 10/21] docs(webworker): define the preview example seed --- ...6-08-20-webworker-pack-lowering-and-preview.i18n.yaml | 4 ++-- .../2026-08-20-webworker-pack-lowering-and-preview.md | 9 +++++++++ .../2026-08-20-webworker-pack-lowering-and-preview.zh.md | 9 +++++++++ packages/experimental/webworker-packer/README.i18n.yaml | 4 ++-- packages/experimental/webworker-packer/README.md | 4 +++- packages/experimental/webworker-packer/README.zh.md | 4 +++- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 6 +++--- packages/experimental/webworker-runtime/README.zh.md | 6 +++--- 9 files changed, 36 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml index b93c9676cb..b4cef0f471 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md -2026-08-20-webworker-pack-lowering-and-preview.md: 37c7730c5da664560156a8e4bd9ba594c78369ee -2026-08-20-webworker-pack-lowering-and-preview.zh.md: 09d9356ee8744ba2406bd765a6ca6a060f93853a +2026-08-20-webworker-pack-lowering-and-preview.md: 72a6ccf856c95f835e103bd355223bf3cf42f692 +2026-08-20-webworker-pack-lowering-and-preview.zh.md: 074d44833c34a2999a5c47da809533065201b580 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md index 37c7730c5d..72a6ccf856 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md @@ -14,6 +14,8 @@ The browser worker can neither compile modules at load nor be served by the prod **The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `` at serve time, keeping the on-disk pages byte-shared. +**The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree. + Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`. ## Alternatives considered @@ -26,6 +28,12 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental **Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering. +**Generate example state in the Worker at startup.** A preview-only Session or Workspace creation branch would bypass cold persistence loading and make the runtime own test data. Static image files exercise the same discovery and pagination path as existing user data. + +**Seed the example through WebFS.** WebFS owns user-selected durable storage and its lifecycle. Coupling the built-in demonstration to it would make a static preview depend on browser persistence state and would blur which bytes came from the deployment. + +**Pack one complete base image per fixture.** Full-image variants duplicate the runtime package closure and make combinations quadratic. Restricted overlays keep one immutable base, let the chooser compose zero or more data sources, and give future WebFS hydration the same pre-boot application point. + ## Consequences - `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed). @@ -33,3 +41,4 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental - The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not. - The served `` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base. - The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads. +- The preview waits at a pre-boot source chooser. Its built-in example opens a reproducible Workspace and cold Session corpus suitable for inspecting tool cards, subagent navigation, and backward pagination without credentials or model calls; the empty selection preserves first-run coverage. Fixture tests validate the physical logs through production readers, and browser acceptance verifies both selections. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md index 09d9356ee8..074d44833c 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md @@ -14,6 +14,8 @@ **preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。 +**仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。 + 两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。 ## 曾考虑的替代方案 @@ -26,6 +28,12 @@ **用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。 +**在 Worker 启动时生成示例状态。** Preview 专用的 Session 或 Workspace 创建分支会绕过冷 persistence 读取,还会让 runtime 拥有测试数据。静态镜像文件与既有用户数据经过相同的发现和分页路径。 + +**通过 WebFS 注入示例。** WebFS 拥有用户选定的 durable storage 及其生命周期。让内置演示依赖它,会使静态 preview 受浏览器持久化状态影响,并模糊哪些字节来自部署。 + +**每套 fixture 各打一份完整基础镜像。** 完整镜像变体会重复 runtime package closure,并使组合数量平方增长。受限 overlay 只保留一个不可变基础镜像,选择面板可组合零到多个数据源,未来 WebFS 水合也能复用同一个 pre-boot 应用点。 + ## 后果 - `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。 @@ -33,3 +41,4 @@ - 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。 - served 的 `` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。 - 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。 +- Preview 会停在 pre-boot 来源选择面板。内置示例提供可复现的 Workspace 与冷 Session 语料,无凭据、零模型调用即可检查工具卡、subagent 导航和向前分页;空白选项保留首次启动覆盖。Fixture 测试通过生产 reader 校验物理日志,浏览器验收同时验证两种选择。 diff --git a/packages/experimental/webworker-packer/README.i18n.yaml b/packages/experimental/webworker-packer/README.i18n.yaml index 040b58adda..5e96d5f120 100644 --- a/packages/experimental/webworker-packer/README.i18n.yaml +++ b/packages/experimental/webworker-packer/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md -README.md: a04f7579c8b3ddc7e94d2f8ed21251ed3efae302 -README.zh.md: 7876212ffb6ded5c45659502306758d8dd316f67 +README.md: 39d084bc631db387a3b6e526a3a374bc9f766577 +README.zh.md: 11aa04f3db36c09525bc4d4945f77e278602cc0f diff --git a/packages/experimental/webworker-packer/README.md b/packages/experimental/webworker-packer/README.md index a04f7579c8..39d084bc63 100644 --- a/packages/experimental/webworker-packer/README.md +++ b/packages/experimental/webworker-packer/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The VFS image packer: turns one composed profile into the single gzip-compressed tar the browser worker inflates and mounts as its filesystem ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships. +The VFS image packer: turns one composed profile into the gzip-compressed base tar the browser worker mounts as its filesystem, and opaque data trees into ordered overlay tars ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the base image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships. The pack is a three-layer standard stack: @@ -12,6 +12,8 @@ The pack is a three-layer standard stack: `repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `native/landlock-run/packages/`, and `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The native scan makes the Landlock entry package an ordinary published-view dependency while its executable remains a Worker platform implementation. The CLI is `dsh-pack-vfs-image --out [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build. +The repository adapter also declares the preview-only fixture trees under `webworker-runtime/tests/fixtures/`. The CLI packs each named fixture into a separate deterministic overlay archive plus a browser-readable manifest. Overlay files bypass npm publish-view and module-reachability exclusions, so dot directories and example source files remain intact; their mounts are limited to `home/` and `workspace/`. `pack.ts` treats them as opaque bytes, and Session and Workspace interpretation stays in the runtime packages that own those formats. + ## Model Experience None, as this package runs at build time and writes an image file; nothing it produces reaches a model request on its own. diff --git a/packages/experimental/webworker-packer/README.zh.md b/packages/experimental/webworker-packer/README.zh.md index 7876212ffb..11aa04f3db 100644 --- a/packages/experimental/webworker-packer/README.zh.md +++ b/packages/experimental/webworker-packer/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后当文件系统挂载的单个 gzip 压缩 tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。 +VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 挂载为文件系统的 gzip 压缩基础 tar,并把不透明数据目录变成按序应用的 overlay tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——基础镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。 打包是三层标准栈: @@ -12,6 +12,8 @@ VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后 `repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`native/landlock-run/packages/` 与 `apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。Native 扫描使 Landlock 入口包成为普通发布视图依赖,其可执行文件仍由 Worker 平台实现。CLI 为 `dsh-pack-vfs-image --out [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。 +仓库适配层还声明 `webworker-runtime/tests/fixtures/` 下仅用于 preview 的 fixture tree。CLI 会把每套具名 fixture 打成一份独立的确定性 overlay 归档,并写出浏览器可读的 manifest。Overlay 文件绕过 NPM 发布视图和模块可达性排除规则,因此点目录与示例源码会完整保留;其挂载位置仅限 `home/` 与 `workspace/`。`pack.ts` 把它们视为不透明字节;Session 与 Workspace 的解释仍归拥有这些格式的 runtime 包。 + ## 模型体验 无:本包在构建期运行并写出镜像文件,其产物本身不进入任何模型请求。 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 6dc2eb15e7..f5d10e0a50 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 88d4213b5eb2f82cc41ad5059d9473d4a8abf53b -README.zh.md: 6f4fd2612d5daa28831890ff64171ad780274958 +README.md: 5b63856b826d4f8bc8b6ff56626c6e7a1ed663b1 +README.zh.md: 59b31bd308077b8eeaa60edf5bd23a75924eaa07 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 88d4213b5e..5b63856b82 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -6,12 +6,12 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: -- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. +- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. -Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium. +Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. ## Model Experience diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 6f4fd2612d..59b31bd308 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -6,12 +6,12 @@ 一条 tsdown 管线出三个产物: -- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 +- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 -验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。 +验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 ## 模型体验 From e883dc235474ff37fc49bad99ee6802ee68cb933 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:53:28 +0800 Subject: [PATCH 11/21] feat(webworker): add selectable preview fixtures --- apps/web/src/preview.ts | 16 +- apps/web/tests/preview-boot.e2e.ts | 235 ++++++++-- .../preview-boot/source-chooser.expected.md | 15 + knip.json | 3 +- .../webworker-packer/package.json | 2 +- .../experimental/webworker-packer/src/bin.ts | 44 +- .../webworker-packer/src/index.ts | 7 +- .../experimental/webworker-packer/src/pack.ts | 66 ++- .../webworker-packer/src/repository.ts | 34 +- .../tests/image-loadable.spec.ts | 30 +- .../webworker-runtime/package.json | 3 + .../webworker-runtime/src/client/client.ts | 5 +- .../webworker-runtime/src/client/index.ts | 76 ++- .../src/client/source-chooser.ts | 178 ++++++++ .../webworker-runtime/src/fixture-manifest.ts | 67 +++ .../webworker-runtime/src/image-layout.ts | 12 +- .../webworker-runtime/src/index.ts | 8 +- .../builtin_modules/implemented/fs-watch.ts | 1 + .../builtin_modules/implemented/stream.ts | 2 + .../webworker-runtime/src/shell/fs-access.ts | 18 +- .../webworker-runtime/src/storage/memory.ts | 36 ++ .../webworker-runtime/src/transport/frames.ts | 10 +- .../webworker-runtime/src/worker-host.ts | 12 +- .../webworker-runtime/src/worker.ts | 10 +- .../tests/client/source-chooser.spec.ts | 150 ++++++ .../tests/fixture-manifest.spec.ts | 48 ++ .../preview-architecture-review/session.jsonl | 178 ++++++++ .../preview-follow-up-builder/session.jsonl | 8 + .../preview-showcase/session.jsonl | 200 ++++++++ .../home/storages/session_projcache.json | 24 + .../.agents/skills/preview-tour/SKILL.md | 8 + .../fixtures/vfs-example/workspace/PREVIEW.md | 9 + .../vfs-example/workspace/data/tasks.json | 17 + .../vfs-example/workspace/src/preview.ts | 3 + .../tests/node/child-process.spec.ts | 7 +- .../tests/storage/tar.spec.ts | 19 +- .../tests/transport/frames.spec.ts | 22 + .../tests/transport/tunnel-client.spec.ts | 21 + .../tests/vfs-example-fixture.spec.ts | 118 +++++ .../tests/vfs-example-fixture.ts | 432 ++++++++++++++++++ pnpm-lock.yaml | 9 + scripts/session-fixture-layout.spec.ts | 14 +- scripts/session-fixture-layout.ts | 15 + 43 files changed, 2084 insertions(+), 108 deletions(-) create mode 100644 apps/web/tests/snapshots/preview-boot/source-chooser.expected.md create mode 100644 packages/experimental/webworker-runtime/src/client/source-chooser.ts create mode 100644 packages/experimental/webworker-runtime/src/fixture-manifest.ts create mode 100644 packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json create mode 100644 packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts create mode 100644 packages/experimental/webworker-runtime/tests/transport/frames.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts create mode 100644 packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts diff --git a/apps/web/src/preview.ts b/apps/web/src/preview.ts index 586cbcab5d..f3d51f8bbc 100644 --- a/apps/web/src/preview.ts +++ b/apps/web/src/preview.ts @@ -1,12 +1,14 @@ /** * Worker-preview bootstrap: the one module preview.html adds ahead of the - * stock entry tag. Connecting the worker host installs the boot globals and - * settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds, - * so everything after this module is the served startup chain verbatim. A - * failed handshake rejects the deferred into the boot page's failure - * rendering; this module owns no page painting. + * stock entry tag. The runtime's optional source stage owns the pre-Cordis + * chooser; the unchanged Host connector then owns the Worker handshake. + * Everything after those calls is the served startup chain verbatim. */ import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker' -import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client' +import { + chooseWorkerHostSource, connectWorkerHost, IMAGE_FILE_NAME, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/client' -await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` }) +const image = `preview/${IMAGE_FILE_NAME}` +const source = await chooseWorkerHostSource({ image }) +await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image, overlays: source.overlays }) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 1b8bc85f32..b4675926bd 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -8,27 +8,33 @@ * Two milestones prove that happened — the host's `tree active` boot line, * whose lowering contract must be the one this checkout's packer emits, and the * workspace hero, which paints only after the client tree comes up over the - * tunnel. The same page then creates a Workspace and Session, lists skills, - * and writes through the settings and credentials providers, exercising the - * upstream Chokidar instances over the Worker filesystem implementation. + * tunnel. The same page opens the seeded Workspace and showcase Session, + * verifies its tool/subagent/history examples, then writes through the + * settings and credentials providers. That keeps the upstream Chokidar + * instances exercised over the Worker filesystem implementation. * * The site is served the way a static host serves it: bytes from `dist/` with * no rewrite rules, so a missing file is a 404 rather than the index page. */ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { tmpdir } from 'node:os' -import { extname, join, normalize } from 'node:path' +import { dirname, extname, join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import type { Browser } from 'playwright' import { expect, it } from 'vitest' import { - composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT, + composeProfile, configTrees, indexWorkspacePackages, packVfsImage, packVfsOverlay, + previewFixtures, WRAPPER_CONTRACT, } from '@deepseek-ai/dsh-experimental-webworker-packer' -import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { + IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, +} from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) @@ -36,9 +42,22 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) /** Where the client looks for the image: the runtime's own name, beside the page. */ const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME) +/** Built-in source catalog read by the pre-boot chooser. */ +const FIXTURE_MANIFEST_FILE = join(DIST_ROOT, 'preview', PREVIEW_FIXTURE_MANIFEST_FILE) + +/** Keyless browser golden for the pre-Worker source chooser. */ +const SOURCE_CHOOSER_EXPECTED = fileURLToPath(new URL('./snapshots/preview-boot/source-chooser.expected.md', import.meta.url)) + +const SNAPSHOT_MODE = webSnapshotMode() + /** Profile the preview deployment composes; `build:preview` packs the same one. */ const PROFILE = 'web' +/** Stable labels authored by the deterministic VFS example fixture. */ +const SHOWCASE_TITLE = 'WebWorker Preview Showcase' +const SHOWCASE_TAIL = 'Preview tour complete' +const SHOWCASE_OLDEST = 'History checkpoint 01: verify deterministic preview state.' + /** Pages the preview needs; the Vite build emits both. */ const PAGES = ['index.html', 'preview.html'] @@ -77,6 +96,12 @@ interface Site { close(): Promise } +interface PreviewAssets { + /** Static-host-relative path to a generated file outside `dist/`. */ + readonly overrides: ReadonlyMap + cleanup(): void +} + /** * Fail before the browser opens a page the build never produced. * @throws When either preview page is missing from `dist/`. @@ -89,20 +114,25 @@ function requirePreviewPages(): void { } /** - * The image file to serve, packed here when `dist/` carries none: `pnpm run - * build` emits the pages but only `build:preview` packs, so this lane packs - * for itself rather than skipping the deployment it is here to accept. An - * image already in place is used as it stands — the worker refuses one lowered - * against another wrapper contract, and that refusal names the rebuild. A - * self-packed image lands in a temp directory, never in `dist/`: the + * The base image, fixture manifest, and overlays to serve, packed here when + * `dist/` does not carry the complete set: `pnpm run build` emits the pages but + * only `build:preview` packs these files, so this lane packs for itself rather + * than skipping the deployment it accepts. A complete built set is used as it + * stands — the worker refuses a base lowered against another wrapper contract. + * Self-packed files land in a temp directory, never in `dist/`: the * client-artifact digest record treats `dist/` as build-owned, so a test write * there fails the record check for every later consumer. - * @returns The file to answer `preview/` with, and its teardown. + * @returns Static-path overrides and their teardown. * @throws When the closure leaves dependencies unresolved, which would pack an * incomplete image the tree fails on later and further from the cause. */ -function requireVfsImage(): { path: string; cleanup(): void } { - if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} } +function requireVfsAssets(): PreviewAssets { + const fixtureDefinitions = previewFixtures(REPO_ROOT) + const fixtureFiles = fixtureDefinitions.map(fixture => + join(DIST_ROOT, 'preview', 'fixtures', `${fixture.id}.tar.gz`)) + if ([IMAGE_FILE, FIXTURE_MANIFEST_FILE, ...fixtureFiles].every(existsSync)) { + return { overrides: new Map(), cleanup: () => {} } + } const packed = packVfsImage({ config: composeProfile(REPO_ROOT, PROFILE), profile: PROFILE, @@ -114,23 +144,48 @@ function requireVfsImage(): { path: string; cleanup(): void } { throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`) } const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-')) - const path = join(directory, IMAGE_FILE_NAME) - writeFileSync(path, packed.image) - return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } } + const overrides = new Map() + const writeAsset = (relativePath: string, bytes: Uint8Array | string): void => { + const path = join(directory, relativePath) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, bytes) + overrides.set(relativePath, path) + } + writeAsset(`preview/${IMAGE_FILE_NAME}`, packed.image) + const fixtures = fixtureDefinitions.map((fixture) => { + const relativePath = `preview/fixtures/${fixture.id}.tar.gz` + writeAsset(relativePath, packVfsOverlay(fixture.trees).image) + return { + id: fixture.id, + label: fixture.label, + description: fixture.description, + overlays: [`fixtures/${fixture.id}.tar.gz`], + } + }) + const manifest: PreviewFixtureManifest = { + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: fixtures[0]?.id ?? null, + fixtures, + } + writeAsset(`preview/${PREVIEW_FIXTURE_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`) + return { overrides, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } } } /** - * Answer one request with the file it names under `dist/`; the image path - * answers from wherever {@link requireVfsImage} put the file. + * Answer one request with its generated override or the file under `dist/`. * @param request - Incoming request; only its path is read. * @param response - Response to write the bytes or the 404 to. - * @param imagePath - File behind `preview/`. + * @param overrides - Generated deployment files used when `dist/` has none. */ -async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise { +async function respond( + request: IncomingMessage, + response: ServerResponse, + overrides: ReadonlyMap, +): Promise { const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '') try { - const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative)) + const body = await readFile(overrides.get(relative) ?? join(DIST_ROOT, relative)) response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' }) response.end(body) } catch { @@ -144,11 +199,11 @@ async function respond(request: IncomingMessage, response: ServerResponse, image /** * Serve `dist/` over loopback with static-host semantics. - * @param imagePath - File behind `preview/`. + * @param overrides - Generated deployment files used when `dist/` has none. * @returns The origin to navigate, and its teardown. */ -async function serveDist(imagePath: string): Promise { - const server = createServer((request, response) => { void respond(request, response, imagePath) }) +async function serveDist(overrides: ReadonlyMap): Promise { + const server = createServer((request, response) => { void respond(request, response, overrides) }) await new Promise((listening) => { server.listen(0, '127.0.0.1', listening) }) const address = server.address() if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port') @@ -188,12 +243,13 @@ async function within(work: Promise, ms: number, stalled: string): Promise it('boots the packed worker deployment to an interactive page', async () => { requirePreviewPages() - const image = requireVfsImage() + const assets = requireVfsAssets() try { - const site = await serveDist(image.path) + const site = await serveDist(assets.overrides) try { const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }) try { + await bootEmptyPreview(site.origin, browser) await bootPreview(site.origin, browser) } finally { await browser.close() @@ -202,7 +258,7 @@ it('boots the packed worker deployment to an interactive page', async () => { await site.close() } } finally { - image.cleanup() + assets.cleanup() } }, 600_000) @@ -227,26 +283,34 @@ async function bootPreview(origin: string, browser: Browser): Promise { }) try { await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' }) + await page.getByRole('heading', { name: '选择 Preview 数据源' }).waitFor() + expect(await page.locator('input[name="preview-source"][value="vfs-example"]').isChecked()).toBe(true) + expect(await page.getByText('空白环境', { exact: true }).count()).toBe(1) + expect(await page.getByText('WebFS 目录', { exact: true }).count()).toBe(1) + expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) + expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) + await compareOrRefreshGolden( + SOURCE_CHOOSER_EXPECTED, + await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), + SNAPSHOT_MODE, + ) + await page.getByRole('button', { name: '启动 Preview' }).click() const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`) // The activated tree ran bodies lowered against the contract this // checkout's packer emits; a dist built before a contract change would // report the older one. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) + expect(bootLine).toContain('data overlays=1') // The hero's workspace picker is the client tree's first interactive // surface, so it appears only once the startup chain completed over the // tunnel. await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) const continueButton = page.getByRole('button', { name: 'Continue' }) - if (await continueButton.isVisible()) await continueButton.click() - await page.getByRole('button', { name: 'Configure later' }).click() - await page.getByRole('textbox', { name: 'Choose workspace' }).click() - const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Edit path' }).click() - const pathInput = dialog.getByRole('textbox', { name: 'Edit path' }) - await pathInput.fill('/dsh/workspace') - await pathInput.press('Enter') - await dialog.getByRole('button', { name: 'Open', exact: true }).click() + await continueButton.waitFor({ timeout: 30_000 }) + await continueButton.click() + const configureLater = page.getByRole('button', { name: 'Configure later' }) + await configureLater.waitFor({ timeout: 30_000 }) + await configureLater.click() await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') .waitFor({ timeout: 30_000 }) @@ -296,9 +360,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const refreshed = await api.skills.list({ sessionId }) if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) } - await createDirectory('/dsh/workspace', '.agents') - await createDirectory('/dsh/workspace/.agents', 'skills') - await createDirectory('/dsh/workspace/.agents/skills', 'placeholder') + await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created') const settings = await api.settings.describe({}) if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`) const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell') @@ -317,8 +379,31 @@ async function bootPreview(origin: string, browser: Browser): Promise { credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured, } }) - expect(exercised.skillCount).toBeGreaterThanOrEqual(0) + expect(exercised.skillCount).toBeGreaterThan(0) expect(exercised.credentialConfigured).toBe(true) + + const sessions = page.getByRole('tree', { name: 'Sessions' }) + const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE }) + await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1) + await showcase.click() + await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) + + expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) + await page.getByText('PREVIEW.md', { exact: true }).waitFor() + await page.getByText('src/preview.ts', { exact: true }).waitFor() + await page.getByText('Update to-do list', { exact: true }).waitFor() + await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() + + const subagents = page.getByRole('button', { name: '2 subagents' }) + await subagents.waitFor({ timeout: 15_000 }) + await subagents.hover() + const catalog = page.getByRole('tree', { name: 'Subagent sessions' }) + await catalog.getByRole('treeitem', { name: /Review preview architecture/ }).waitFor() + await catalog.getByRole('treeitem', { name: /Continue preview verification/ }).waitFor() + await catalog.press('Escape') + + await page.getByRole('button', { name: 'Load earlier', exact: true }).click() + await page.getByText(SHOWCASE_OLDEST, { exact: true }).waitFor({ timeout: 15_000 }) expect(pageErrors.map(error => error.message)).toEqual([]) expect(consoleErrors.filter(line => /watchFile|failed to watch|node-addon-landlock-run\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([]) @@ -329,3 +414,65 @@ async function bootPreview(origin: string, browser: Browser): Promise { : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors') } } + +/** Verify the chooser can boot the untouched base image and reach first-run UI. */ +async function bootEmptyPreview(origin: string, browser: Browser): Promise { + const page = await newEnglishPage(browser) + const pageErrors: Error[] = [] + const consoleErrors: string[] = [] + const failedResponses: string[] = [] + page.on('pageerror', (error) => { pageErrors.push(error) }) + page.on('response', (response) => { + if (response.status() >= 400) failedResponses.push(new URL(response.url()).pathname) + }) + const treeActive = new Promise((reported) => { + page.on('console', (message) => { + const text = message.text() + if (text.includes(TREE_ACTIVE)) reported(text) + if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text) + }) + }) + try { + await page.goto(`${origin}/preview.html?preview-fixture=none`, { waitUntil: 'domcontentloaded' }) + expect(await page.getByRole('heading', { name: '选择 Preview 数据源' }).count()).toBe(0) + const bootLine = await within( + treeActive, + BOOT_TIMEOUT_MS, + `empty preview boot: the worker never reported "${TREE_ACTIVE}"`, + ) + expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) + expect(bootLine).toContain('data overlays=0') + await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + const sessionCount = await page.evaluate(async () => { + const transport = (globalThis as typeof globalThis & { + __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise } + }).__DSH_TRANSPORT__ + if (transport === undefined) throw new Error('empty preview transport is absent after boot') + const response = await transport.fetch('/api/session/list', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'empty-preview-session-list', method: 'session/list', + payload: { args: { _request: {} } }, + }), + }) + const body = await response.json() as { + result: { ok: true; value: { items: unknown[] } } | { ok: false; error: { message: string } } + } + if (!body.result.ok) throw new Error(`empty session/list failed: ${body.result.error.message}`) + return body.result.value.items.length + }) + expect(sessionCount).toBe(0) + expect(pageErrors.map(error => error.message)).toEqual([]) + expect(failedResponses).toEqual(['/plugins/events']) + expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404'))) + .toEqual([]) + } catch (error) { + await saveFailureShot(page, 'preview-boot-empty') + throw pageErrors.length === 0 + ? error + : new AggregateError([error, ...pageErrors], 'empty preview boot failed, with uncaught page errors') + } finally { + await page.close() + } +} diff --git a/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md new file mode 100644 index 0000000000..18dd2a442b --- /dev/null +++ b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md @@ -0,0 +1,15 @@ +- form "选择 Preview 数据源": + - heading "选择 Preview 数据源" [level=1] + - paragraph: 数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。 + - group "文件系统来源": + - text: 文件系统来源 + - radio "空白环境 只加载基础运行时,用于验证首次启动与新建 Workspace。" + - strong: 空白环境 + - text: 只加载基础运行时,用于验证首次启动与新建 Workspace。 + - radio "内置综合示例 示例 Workspace、工具卡、子代理与分页会话。" [checked] + - strong: 内置综合示例 + - text: 示例 Workspace、工具卡、子代理与分页会话。 + - radio "WebFS 目录 需要用户授权的目录来源,将在 WebFS provider 接入后开放。" [disabled] + - strong: WebFS 目录 + - text: 需要用户授权的目录来源,将在 WebFS provider 接入后开放。 + - button "启动 Preview" diff --git a/knip.json b/knip.json index b457dd54f3..b6c4e33254 100644 --- a/knip.json +++ b/knip.json @@ -241,7 +241,8 @@ "packages/experimental/webworker-runtime": { "entry": [ "tests/**/*.spec.ts", - "tests/compile/transform-corpus-check.ts" + "tests/compile/transform-corpus-check.ts", + "tests/fixtures/vfs-example/workspace/src/preview.ts" ], "project": [ "src/**/*.ts", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index e9219b35f9..a8155676dd 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", - "description": "Build-time packer for the browser runtime's VFS image: materializes a profile's package closure into one gzip-compressed tar the worker mounts, with every module body pre-transformed", + "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", "version": "0.1.1-rc.2", "private": true, "repository": { diff --git a/packages/experimental/webworker-packer/src/bin.ts b/packages/experimental/webworker-packer/src/bin.ts index 57a3c77730..093fac23d5 100644 --- a/packages/experimental/webworker-packer/src/bin.ts +++ b/packages/experimental/webworker-packer/src/bin.ts @@ -1,17 +1,23 @@ #!/usr/bin/env node /** - * Pack a VFS image from this repository: compose the profile, materialize the - * closure, lower every module body, write the gzip-compressed tar. + * Pack a Preview deployment from this repository: compose and lower the base + * image, then write each named fixture overlay and their manifest. * * Usage: dsh-pack-vfs-image --out [--profile web] [--root /dsh] * node --import tsx/esm src/bin.ts --out ../../apps/web/dist/preview/vfs-image.tar.gz * @module @deepseek-ai/dsh-experimental-webworker-packer/src/bin */ import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, isAbsolute, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { packVfsImage } from './pack.ts' -import { composeProfile, configTrees, describePack, indexWorkspacePackages } from './repository.ts' +import { + PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, +} from '@deepseek-ai/dsh-experimental-webworker-runtime' +import { packVfsImage, packVfsOverlay } from './pack.ts' +import { + composeProfile, configTrees, describePack, indexWorkspacePackages, previewFixtures, +} from './repository.ts' /** * Read one `--flag value` pair. @@ -54,4 +60,30 @@ if (result.missing.length > 0) { mkdirSync(dirname(outputFile), { recursive: true }) writeFileSync(outputFile, result.image) -process.stdout.write(describePack(result, repoRoot, outputFile).join('\n')) + +const fixtureDefinitions = previewFixtures(repoRoot) +const fixtureDirectory = join(dirname(outputFile), 'fixtures') +mkdirSync(fixtureDirectory, { recursive: true }) +const fixtureLines: string[] = [] +const fixtures = fixtureDefinitions.map((fixture) => { + const packed = packVfsOverlay(fixture.trees) + const file = `fixtures/${fixture.id}.tar.gz` + writeFileSync(join(dirname(outputFile), file), packed.image) + fixtureLines.push(` fixture overlay ${fixture.id} (${String(packed.image.byteLength)} B compressed)`) + return { + id: fixture.id, + label: fixture.label, + description: fixture.description, + overlays: [file], + } +}) +const manifest: PreviewFixtureManifest = { + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: fixtures[0]?.id ?? null, + fixtures, +} +writeFileSync( + join(dirname(outputFile), PREVIEW_FIXTURE_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n`, +) +process.stdout.write([...describePack(result, repoRoot, outputFile), ...fixtureLines, ''].join('\n')) diff --git a/packages/experimental/webworker-packer/src/index.ts b/packages/experimental/webworker-packer/src/index.ts index ea054b26b0..ea203a6f7a 100644 --- a/packages/experimental/webworker-packer/src/index.ts +++ b/packages/experimental/webworker-packer/src/index.ts @@ -7,9 +7,10 @@ export { type ImageFiles, type TransformOutcome, } from './transform-image.ts' export { - CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, - type ConfigTree, type PackOptions, type PackResult, + CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay, + type ConfigTree, type ImageTree, type PackOptions, type PackOverlayResult, type PackResult, } from './pack.ts' export { - composeProfile, configTrees, describePack, indexWorkspacePackages, + composeProfile, configTrees, describePack, indexWorkspacePackages, previewFixtures, + type PreviewFixture, } from './repository.ts' diff --git a/packages/experimental/webworker-packer/src/pack.ts b/packages/experimental/webworker-packer/src/pack.ts index a5dcb00156..3e86610233 100644 --- a/packages/experimental/webworker-packer/src/pack.ts +++ b/packages/experimental/webworker-packer/src/pack.ts @@ -19,6 +19,7 @@ import { gzipSync } from 'node:zlib' import { lowerModuleSource, MemoryVfs, packTar, WorkerModuleLoader, DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_MANIFEST_PATH, + IMAGE_OVERLAY_DIRECTORIES, } from '@deepseek-ai/dsh-experimental-webworker-runtime' import picomatch from 'picomatch' import yaml from 'js-yaml' @@ -52,12 +53,16 @@ const workspaceExcluded = picomatch([...EXCLUDE, ...EXCLUDE_WORKSPACE], { dot: t /** Page-asset matcher over image paths ({@link PAGE_ASSETS}). */ const pageAsset = picomatch([...PAGE_ASSETS], { dot: true }) -/** One directory tree to copy in verbatim beside the composition. */ -export interface ConfigTree { +/** One directory tree to copy into the image at a caller-selected mount. */ +export interface ImageTree { /** Image path to mount it at, relative to the virtual root. */ readonly mount: string /** Absolute source directory. */ readonly directory: string +} + +/** One configuration tree whose plugin rows may extend the package roster. */ +export interface ConfigTree extends ImageTree { /** * Whether plugin names inside its `.yml` files join the materialization closure. * An agent preset mounts plugins the base composition never lists, and creating a @@ -119,6 +124,14 @@ export interface PackResult { readonly contract: string } +/** One deterministic data-overlay archive and its uncompressed entries. */ +export interface PackOverlayResult { + /** Gzip-compressed ustar bytes consumed by the Worker host. */ + readonly image: Uint8Array + /** Every path in the overlay before compression. */ + readonly files: ImageFiles +} + const readJson = (file: string): Record => JSON.parse(readFileSync(file, 'utf8')) as Record @@ -204,20 +217,28 @@ function resolveDependency(fromDirectory: string, name: string): string | undefi /** * Collect files under one directory. Traversal mechanics live here — nested - * `node_modules` never mounts (the image is flat) and dot directories are - * tooling residue at any depth — while every judgement call comes in through - * `keep` (the {@link EXCLUDE} tables and the npm publish view). + * package/config collection flattens nested `node_modules` and prunes dot + * directories, while seed collection preserves every directory. Every file + * judgement comes in through `keep` (the {@link EXCLUDE} tables and the npm + * publish view, or an unconditional seed predicate). * @param root - Source directory. * @param into - Image entries to add to. * @param prefix - Image path prefix. * @param keep - Filter over root-relative paths. + * @param preserveDirectories - Whether dot directories and nested `node_modules` + * are ordinary fixture content rather than package-manager residue. */ -function collectTree(root: string, into: ImageFiles, prefix: string, keep: (relativePath: string) => boolean): void { +function collectTree( + root: string, + into: ImageFiles, + prefix: string, + keep: (relativePath: string) => boolean, + preserveDirectories = false, +): void { const walk = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { if (entry.isDirectory()) { - if (entry.name === 'node_modules') continue - if (entry.name.startsWith('.')) continue + if (!preserveDirectories && (entry.name === 'node_modules' || entry.name.startsWith('.'))) continue walk(join(directory, entry.name)) continue } @@ -621,3 +642,32 @@ export function packVfsImage(options: PackOptions): PackResult { contract: WRAPPER_CONTRACT, } } + +/** + * Pack opaque data trees into one ordered VFS overlay. + * + * Overlay mounts are restricted to the runtime-owned data directories, so an + * overlay cannot replace configuration, the lowering manifest, or modules. + * Files bypass package excludes and module reachability processing; later + * trees replace earlier files at the same path. + * @param trees - Absolute source directories and their data-directory mounts. + * @returns Deterministic compressed archive plus its uncompressed entries. + */ +export function packVfsOverlay(trees: readonly ImageTree[]): PackOverlayResult { + const files: ImageFiles = {} + for (const tree of trees) { + if (!existsSync(tree.directory)) { + throw new Error(`vfs overlay: tree ${tree.mount} is missing at ${tree.directory}`) + } + const mount = tree.mount.replace(/^\.\//, '').replace(/\/$/, '') + const first = mount.split('/')[0] + if (mount === '' || first === undefined || !IMAGE_OVERLAY_DIRECTORIES.includes(first) + || mount.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) { + throw new Error( + `vfs overlay: mount ${JSON.stringify(tree.mount)} must stay under ${IMAGE_OVERLAY_DIRECTORIES.join(' or ')}`, + ) + } + collectTree(tree.directory, files, mount, () => true, true) + } + return { image: compressImage(packTar(files)), files } +} diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts index 6ae2e59bee..5e433ea425 100644 --- a/packages/experimental/webworker-packer/src/repository.ts +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -12,7 +12,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node import { tmpdir } from 'node:os' import { join, relative } from 'node:path' import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths' -import type { ConfigTree, PackResult } from './pack.ts' +import type { ConfigTree, ImageTree, PackResult } from './pack.ts' /** * Repository directories scanned for workspace and vendored packages. The @@ -28,6 +28,21 @@ const CLI_PACKAGE = 'apps/cli' /** Composition entry point: the `dsh` CLI, run from source. */ const CLI_ENTRY = `${CLI_PACKAGE}/src/bin.ts` +/** Repository-owned deterministic filesystem content offered by the preview. */ +const PREVIEW_EXAMPLE_ROOT = 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example' + +/** One built-in Preview source and the trees packed into its overlay. */ +export interface PreviewFixture { + /** URL/query-safe identifier. */ + readonly id: string + /** User-facing chooser label. */ + readonly label: string + /** User-facing chooser detail. */ + readonly description: string + /** Opaque trees packed into this fixture's overlay archive. */ + readonly trees: readonly ImageTree[] +} + /** * Index every workspace and vendored package by name. * @param repoRoot - Absolute repository root. @@ -130,6 +145,23 @@ export function configTrees(repoRoot: string): ConfigTree[] { }) } +/** + * Built-in filesystem fixtures offered by the repository preview. + * Session and Workspace semantics remain opaque here; the owning runtime tests + * validate those files through their production readers. + * @param repoRoot - Absolute repository root. + * @returns Named chooser entries and their overlay trees. + */ +export function previewFixtures(repoRoot: string): PreviewFixture[] { + const root = join(repoRoot, PREVIEW_EXAMPLE_ROOT) + return [{ + id: 'vfs-example', + label: '内置综合示例', + description: '示例 Workspace、工具卡、子代理与分页会话。', + trees: ['home', 'workspace'].map(mount => ({ mount, directory: join(root, mount) })), + }] +} + /** * Render one pack as the lines a build log should carry. * diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index e75b4f523b..609e3ccf80 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -26,8 +26,8 @@ import { } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' -import { indexWorkspacePackages } from '../src/repository.ts' -import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage } from '../src/pack.ts' +import { indexWorkspacePackages, previewFixtures } from '../src/repository.ts' +import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay } from '../src/pack.ts' const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -37,6 +37,32 @@ const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' const workspaces = indexWorkspacePackages(repoRoot) +describe('preview example overlays', () => { + it('packs source-looking paths and dot directories into a separate overlay', () => { + const fixture = previewFixtures(repoRoot)[0] + expect(fixture?.id).toBe('vfs-example') + const result = packVfsOverlay(fixture?.trees ?? []) + expect(new TextDecoder().decode(result.files['workspace/src/preview.ts'])) + .toContain("previewStatus = 'ready'") + expect(new TextDecoder().decode(result.files['workspace/.agents/skills/preview-tour/SKILL.md'])) + .toContain('name: preview-tour') + expect(Object.keys(result.files).filter(path => path.endsWith('/session.jsonl'))).toHaveLength(3) + }) + + it('fails loud when a declared seed tree is absent', () => { + expect(() => packVfsOverlay([ + { mount: 'workspace', directory: join(repoRoot, 'missing-preview-seed') }, + ])).toThrow(/tree workspace is missing/) + }) + + it('refuses overlays that could replace runtime files', () => { + const fixture = previewFixtures(repoRoot)[0] + expect(() => packVfsOverlay([ + { mount: 'config', directory: fixture?.trees[0]?.directory ?? repoRoot }, + ])).toThrow(/must stay under home or workspace/) + }) +}) + /** * The pack consumes built `lib/` output. An unbuilt checkout (the unit * coverage lane runs before any build) self-skips; the built lanes and every diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 422bd4f5ec..68643425f8 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -57,6 +57,9 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:^", "@types/picomatch": "^3.0.2", diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 7fa05a5355..0300347297 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -156,9 +156,10 @@ export class WorkerTunnel { /** * Open the tunnel: the worker assembles its host from this frame. * @param image - VFS image URL the worker fetches. + * @param overlays - Ordered data overlay URLs applied before boot. */ - init(image: string): void { - this.worker.postMessage({ t: 'init', image }) + init(image: string, overlays: readonly string[] = []): void { + this.worker.postMessage({ t: 'init', image, overlays }) } /** Fetch-shaped entry: one request frame, one Response (streamed when the worker streams). */ diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index c84f637896..8494e1c06a 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -9,14 +9,20 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client */ import { IMAGE_FILE_NAME } from '../image-layout.ts' +import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' import { WorkerApiClient } from './api-client.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' +import { choosePreviewSource } from './source-chooser.ts' export { WorkerApiClient } from './api-client.ts' export { WorkerTunnel, type TunnelFetch } from './client.ts' export { applyIndexInjections } from './apply-injections.ts' export { IMAGE_FILE_NAME } from '../image-layout.ts' +export { + parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, type PreviewFixtureManifestEntry, +} from '../fixture-manifest.ts' /** Transport global the connection plugin reads instead of building an HTTP carrier. */ interface ClientTransportGlobal { @@ -35,9 +41,25 @@ export interface WorkerHostConnectOptions { /** * VFS image URL, the one deployment-shaped input. Defaults to * {@link IMAGE_FILE_NAME} beside the page; a deployment that packs the - * image elsewhere passes its own URL. + * image elsewhere passes its own URL. Data overlays are independent. */ readonly image?: string | URL + /** Ordered data overlay URLs, resolved against the page like the base image. */ + readonly overlays?: readonly (string | URL)[] +} + +/** Inputs for the optional pre-boot filesystem-source chooser. */ +export interface WorkerHostSourceOptions { + /** Base VFS image URL; defaults to {@link IMAGE_FILE_NAME} beside the page. */ + readonly image?: string | URL + /** Fixture catalog URL; defaults to {@link PREVIEW_FIXTURE_MANIFEST_FILE} beside the image. */ + readonly fixtureManifest?: string | URL +} + +/** Filesystem inputs selected before {@link connectWorkerHost}. */ +export interface WorkerHostSource { + /** Ordered data overlays to pass through unchanged to the Host connection. */ + readonly overlays: readonly URL[] } /** A page connected to a worker-hosted harness, ready to run a shell entry. */ @@ -53,12 +75,51 @@ interface BootReadyGlobal { __DSH_BOOT_READY__?: PromiseWithResolvers } +function bootReadyGate(): PromiseWithResolvers { + return (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers() +} + +/** + * Install the page boot barrier before an asynchronous source chooser waits + * for user input. The later {@link connectWorkerHost} call settles the same + * barrier. + */ +function holdWorkerHostBoot(): void { + const ready = bootReadyGate() + // A chooser may remain open indefinitely; if a later connection fails before + // the stock entry subscribes, retain the rejection without browser noise. + void ready.promise.catch(() => {}) +} + +/** + * Run the optional pre-boot source-selection stage. Calling this stage holds + * the stock shell until the caller passes its result to {@link connectWorkerHost}; + * callers that need no chooser call `connectWorkerHost` directly and receive + * the base image with an empty overlay list. + * @param options - Base image and optional fixture-catalog locations. + * @returns The ordered overlays selected by the user. + */ +export async function chooseWorkerHostSource( + options: WorkerHostSourceOptions = {}, +): Promise { + holdWorkerHostBoot() + const image = new URL(options.image ?? IMAGE_FILE_NAME, document.baseURI) + const manifest = new URL(options.fixtureManifest ?? PREVIEW_FIXTURE_MANIFEST_FILE, image) + try { + const overlays = await choosePreviewSource(manifest) + return { overlays } + } catch (reason) { + bootReadyGate().reject(reason) + throw reason + } +} + /** * Connect a spawned host worker and complete the pre-Cordis handshake. * * The caller constructs the Worker so its bundler resolves the bundle URL - * statically; the opening `init` frame then carries the image location, the - * only input the worker takes from outside. + * statically; the opening `init` frame then carries the base image and ordered + * overlay locations. * * Order is fixed by the web boot protocol: the transport global must exist * before any bundle executes; the injection table then reproduces the served @@ -70,17 +131,20 @@ interface BootReadyGlobal { * row has taken effect, and surfaces a failed handshake instead of * proceeding on missing globals. * @param worker - The host worker. - * @param options - Image location override. + * @param options - Base-image and overlay location overrides. * @returns The connection; hand `loadBundle` to the shell entry's boot seam. */ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConnectOptions): Promise { - const ready = (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers() + const ready = bootReadyGate() // The handshake may fail before any entry awaits the promise; this no-op // subscription keeps that from surfacing as an unhandled rejection. void ready.promise.catch(() => {}) try { const tunnel = new WorkerTunnel(worker) - tunnel.init(new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href) + tunnel.init( + new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href, + (options?.overlays ?? []).map(overlay => new URL(overlay, document.baseURI).href), + ) const payload = await tunnel.bootPayload() ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { createApiClient: () => new WorkerApiClient(tunnel), diff --git a/packages/experimental/webworker-runtime/src/client/source-chooser.ts b/packages/experimental/webworker-runtime/src/client/source-chooser.ts new file mode 100644 index 0000000000..6a5174ccd3 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/client/source-chooser.ts @@ -0,0 +1,178 @@ +/** Pre-boot filesystem-source chooser for static WebWorker previews. */ + +import { + parsePreviewFixtureManifest, type PreviewFixtureManifestEntry, +} from '../fixture-manifest.ts' + +const EMPTY_SOURCE = 'none' +const WEBFS_SOURCE = 'webfs' +const PREVIEW_FIXTURE_QUERY = 'preview-fixture' + +interface PreviewSourceChoice { + readonly id: string + readonly label: string + readonly description: string + readonly overlays: readonly URL[] + readonly disabled?: boolean +} + +const CHOOSER_STYLE = ` + :root { color-scheme: light dark; } + body { margin: 0; } + [data-preview-source-chooser] { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + box-sizing: border-box; + color: #171717; + background: radial-gradient(circle at 50% 35%, #eef4ff 0, #f8fafc 42%, #f3f4f6 100%); + font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + [data-preview-source-card] { + width: min(560px, 100%); + box-sizing: border-box; + padding: 28px; + border: 1px solid #d8dee9; + border-radius: 20px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12); + } + [data-preview-source-card] h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.25; } + [data-preview-source-card] > p { margin: 0 0 22px; color: #5b6472; } + [data-preview-source-card] fieldset { display: grid; gap: 10px; margin: 0; padding: 0; border: 0; } + [data-preview-source-card] legend { margin-bottom: 10px; font-weight: 650; } + [data-preview-source-option] { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 12px; + padding: 14px; + border: 1px solid #d8dee9; + border-radius: 12px; + cursor: pointer; + } + [data-preview-source-option]:has(input:checked) { border-color: #4777df; background: #edf3ff; } + [data-preview-source-option]:has(input:disabled) { cursor: not-allowed; opacity: 0.55; } + [data-preview-source-option] input { grid-row: 1 / span 2; margin: 4px 0 0; } + [data-preview-source-option] strong { font-size: 15px; } + [data-preview-source-option] span { color: #667085; } + [data-preview-source-submit] { + width: 100%; + margin-top: 20px; + padding: 11px 16px; + border: 0; + border-radius: 10px; + color: white; + background: #315fc7; + font: inherit; + font-weight: 650; + cursor: pointer; + } + [data-preview-source-submit]:disabled { cursor: not-allowed; opacity: 0.5; } + @media (prefers-color-scheme: dark) { + [data-preview-source-chooser] { color: #f4f4f5; background: radial-gradient(circle at 50% 35%, #172554 0, #111827 45%, #09090b 100%); } + [data-preview-source-card] { border-color: #374151; background: rgba(24, 24, 27, 0.96); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); } + [data-preview-source-card] > p, [data-preview-source-option] span { color: #a1a1aa; } + [data-preview-source-option] { border-color: #3f3f46; } + [data-preview-source-option]:has(input:checked) { border-color: #7aa2ff; background: #172554; } + } +` + +const ENTITIES: Readonly> = { + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +} + +function escapeMarkup(value: string): string { + return value.replace(/[&<>"']/g, character => ENTITIES[character] ?? character) +} + +function optionMarkup(choice: PreviewSourceChoice, selected: string): string { + return `` +} + +function fixtureChoices(entries: readonly PreviewFixtureManifestEntry[], manifestUrl: URL): PreviewSourceChoice[] { + return entries.map(entry => ({ + id: entry.id, + label: entry.label, + description: entry.description, + overlays: entry.overlays.map(overlay => new URL(overlay, manifestUrl)), + })) +} + +/** + * Render the source chooser and wait for an enabled selection. + * @param manifestUrl - Built-in fixture catalog URL. + * @returns Ordered overlay URLs selected for the Worker mount. + */ +export async function choosePreviewSource(manifestUrl: URL): Promise { + const requested = new URL(location.href).searchParams.get(PREVIEW_FIXTURE_QUERY) + if (requested === EMPTY_SOURCE) return [] + + const response = await fetch(manifestUrl) + if (!response.ok) { + throw new Error(`preview source chooser: fixture manifest returned ${String(response.status)}`) + } + const manifest = parsePreviewFixtureManifest(await response.json()) + const choices: PreviewSourceChoice[] = [ + { + id: EMPTY_SOURCE, + label: '空白环境', + description: '只加载基础运行时,用于验证首次启动与新建 Workspace。', + overlays: [], + }, + ...fixtureChoices(manifest.fixtures, manifestUrl), + { + id: WEBFS_SOURCE, + label: 'WebFS 目录', + description: '需要用户授权的目录来源,将在 WebFS provider 接入后开放。', + overlays: [], + disabled: true, + }, + ] + if (requested !== null) { + const requestedChoice = choices.find(choice => choice.id === requested && choice.disabled !== true) + if (requestedChoice === undefined) { + throw new Error(`preview source chooser: unknown or interactive source "${requested}"`) + } + return requestedChoice.overlays + } + + const root = document.getElementById('root') + if (root === null) throw new Error('preview source chooser: missing #root') + const selected = manifest.defaultFixture ?? EMPTY_SOURCE + const style = document.createElement('style') + style.dataset.previewSourceStyle = '' + style.textContent = CHOOSER_STYLE + document.head.append(style) + + root.innerHTML = `
+
+

选择 Preview 数据源

+

数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。

+
+ 文件系统来源 + ${choices.map(choice => optionMarkup(choice, selected)).join('')} +
+ +
+
` + const form = root.querySelector('[data-preview-source-card]') + if (form === null) throw new Error('preview source chooser: form was not rendered') + const sourceId = await new Promise((resolve, reject) => { + form.addEventListener('submit', (event) => { + event.preventDefault() + const value = new FormData(form).get('preview-source') + if (typeof value === 'string') resolve(value) + else reject(new Error('preview source chooser: no source selected')) + }, { once: true }) + }) + const choice = choices.find(candidate => candidate.id === sourceId && candidate.disabled !== true) + if (choice === undefined) throw new Error(`preview source chooser: unavailable source "${sourceId}"`) + root.replaceChildren() + style.remove() + return choice.overlays +} diff --git a/packages/experimental/webworker-runtime/src/fixture-manifest.ts b/packages/experimental/webworker-runtime/src/fixture-manifest.ts new file mode 100644 index 0000000000..d594960576 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/fixture-manifest.ts @@ -0,0 +1,67 @@ +/** Browser-readable catalog of built-in Preview filesystem overlays. */ + +/** Manifest format version emitted beside the base VFS image. */ +export const PREVIEW_FIXTURE_MANIFEST_VERSION = 1 + +/** Leaf name resolved beside the base image. */ +export const PREVIEW_FIXTURE_MANIFEST_FILE = 'fixtures.json' + +/** One selectable built-in fixture and its ordered overlay archives. */ +export interface PreviewFixtureManifestEntry { + readonly id: string + readonly label: string + readonly description: string + readonly overlays: readonly string[] +} + +/** Complete built-in fixture catalog consumed before Worker startup. */ +export interface PreviewFixtureManifest { + readonly version: number + readonly defaultFixture: string | null + readonly fixtures: readonly PreviewFixtureManifestEntry[] +} + +function recordOf(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** + * Validate the static fixture catalog before it controls Worker fetches. + * @param value - Parsed JSON response. + * @returns A detached manifest with unique ids and non-empty overlay lists. + */ +export function parsePreviewFixtureManifest(value: unknown): PreviewFixtureManifest { + const record = recordOf(value) + if (record?.version !== PREVIEW_FIXTURE_MANIFEST_VERSION || !Array.isArray(record.fixtures)) { + throw new Error(`preview fixture manifest must use version ${String(PREVIEW_FIXTURE_MANIFEST_VERSION)}`) + } + const fixtures: PreviewFixtureManifestEntry[] = [] + const ids = new Set() + for (const value of record.fixtures) { + const fixture = recordOf(value) + const id = fixture?.id + const label = fixture?.label + const description = fixture?.description + const overlays = fixture?.overlays + const overlayUrls = Array.isArray(overlays) + ? overlays.filter((overlay): overlay is string => typeof overlay === 'string' && overlay.length > 0) + : [] + if (typeof id !== 'string' || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(id) + || id === 'none' || id === 'webfs' + || typeof label !== 'string' || label.length === 0 + || typeof description !== 'string' || description.length === 0 + || !Array.isArray(overlays) || overlays.length === 0 || overlayUrls.length !== overlays.length) { + throw new Error('preview fixture manifest contains an invalid fixture entry') + } + if (ids.has(id)) throw new Error(`preview fixture manifest repeats id "${id}"`) + ids.add(id) + fixtures.push({ id, label, description, overlays: overlayUrls }) + } + const defaultFixture = record.defaultFixture + if (defaultFixture !== null && (typeof defaultFixture !== 'string' || !ids.has(defaultFixture))) { + throw new Error('preview fixture manifest defaultFixture does not name a fixture') + } + return { version: PREVIEW_FIXTURE_MANIFEST_VERSION, defaultFixture, fixtures } +} diff --git a/packages/experimental/webworker-runtime/src/image-layout.ts b/packages/experimental/webworker-runtime/src/image-layout.ts index 83693ea292..3e16f5a529 100644 --- a/packages/experimental/webworker-runtime/src/image-layout.ts +++ b/packages/experimental/webworker-runtime/src/image-layout.ts @@ -9,9 +9,9 @@ export const DEFAULT_ROOT = '/dsh' /** - * Leaf name of the packed image: one gzip member holding the ustar archive. The - * app build writes it beside the page and the page's boot fetches it from there, - * so the extension is part of what a deployment serves. + * Leaf name of the packed base image: one gzip member holding the ustar archive. + * The app build writes it beside the page and the page's boot fetches it from + * there, so the extension is part of what a deployment serves. */ export const IMAGE_FILE_NAME = 'vfs-image.tar.gz' @@ -27,6 +27,12 @@ export const IMAGE_HOME_DIRECTORY = 'home' /** Working directories the host tree expects to exist, empty. */ export const IMAGE_EMPTY_DIRECTORIES: readonly string[] = ['home/', 'workspace/', 'tmp/'] +/** + * Top-level directories an overlay archive may populate. Runtime code, + * configuration, and the lowering manifest remain owned by the base image. + */ +export const IMAGE_OVERLAY_DIRECTORIES: readonly string[] = ['home', 'workspace'] + /** * Identity of the lowered code shape, recorded in the image manifest by the * packer and required by the worker host: an image lowered by an older transform diff --git a/packages/experimental/webworker-runtime/src/index.ts b/packages/experimental/webworker-runtime/src/index.ts index cf39694ca0..e24faa1eb7 100644 --- a/packages/experimental/webworker-runtime/src/index.ts +++ b/packages/experimental/webworker-runtime/src/index.ts @@ -34,9 +34,13 @@ export { } from './worker-host.ts' export { DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_FILE_NAME, IMAGE_HOME_DIRECTORY, - IMAGE_MANIFEST_PATH, LOWERING_VERSION, WRAPPER_PARAMS, + IMAGE_MANIFEST_PATH, IMAGE_OVERLAY_DIRECTORIES, LOWERING_VERSION, WRAPPER_PARAMS, } from './image-layout.ts' -export { loadVfsImage, MemoryVfs } from './storage/memory.ts' +export { + parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, + type PreviewFixtureManifest, type PreviewFixtureManifestEntry, +} from './fixture-manifest.ts' +export { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts' export { inflateImage, inflateImageStream } from './storage/image-gzip.ts' export { packTar, parseTar, type TarEntry } from './storage/tar.ts' export { requireActiveVfs, setActiveVfs } from './storage/active.ts' diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts index 396ae3c22e..71864a7caf 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -413,6 +413,7 @@ export function watchAsync( throw(reason?: unknown): Promise> { close() // AsyncIterator.throw forwards the caller's exact reason, including non-Error values. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors return Promise.reject(reason) }, } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts index 757961a42a..a7fca23540 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts @@ -23,6 +23,7 @@ type StreamStatics = typeof import('node:stream').Stream & { const nodeStream = Stream as unknown as StreamRuntime +/* oxlint-disable typescript/unbound-method -- readable-stream's namespace statics do not read `this`. */ const { Duplex, PassThrough, Readable, Stream: StreamBase, Transform, Writable, addAbortSignal, compose, destroy, finished, isDisturbed, isErrored, isReadable, pipeline, promises, @@ -31,6 +32,7 @@ const streamStatics = StreamBase as unknown as StreamStatics const { getDefaultHighWaterMark, isDestroyed, isWritable, setDefaultHighWaterMark, } = streamStatics +/* oxlint-enable typescript/unbound-method */ // readable-stream tracks Node 18's 16 KiB byte default; this repository runs // Node 22+, whose generic and file streams use 64 KiB. diff --git a/packages/experimental/webworker-runtime/src/shell/fs-access.ts b/packages/experimental/webworker-runtime/src/shell/fs-access.ts index 48da48302a..9099c43559 100644 --- a/packages/experimental/webworker-runtime/src/shell/fs-access.ts +++ b/packages/experimental/webworker-runtime/src/shell/fs-access.ts @@ -76,13 +76,13 @@ function statsOf(stats: VfsStats): ShellStats { */ export function hostFileSystem(): ShellFileSystem { const vfs = (): ReturnType => requireActiveVfs() - const stat = async (path: string): Promise => { + const stat = (path: string): Promise => { try { - return statsOf(vfs().statSync(path) as VfsStats) + return Promise.resolve(statsOf(vfs().statSync(path) as VfsStats)) } catch { // Absence is the answer callers branch on; every other failure mode of // the in-memory backend is also "this path holds nothing readable". - return undefined + return Promise.resolve(undefined) } } // Several members take no await: the face is asynchronous because a process @@ -101,18 +101,22 @@ export function hostFileSystem(): ShellFileSystem { if ((await stat(path))?.directory === true) throw filesystemError('EISDIR', 'read', path) return vfs().readFileSync(path, 'utf8') as string }, - writeText: async (path: string, text: string, append = false): Promise => { + writeText: (path: string, text: string, append = false): Promise => { if (append) vfs().appendFileSync(path, text) else vfs().writeFileSync(path, text) + return Promise.resolve() }, - mkdir: async (path: string, recursive: boolean): Promise => { + mkdir: (path: string, recursive: boolean): Promise => { vfs().mkdirSync(path, { recursive }) + return Promise.resolve() }, - remove: async (path: string, options: { recursive: boolean; force: boolean }): Promise => { + remove: (path: string, options: { recursive: boolean; force: boolean }): Promise => { vfs().rmSync(path, options) + return Promise.resolve() }, - rename: async (from: string, to: string): Promise => { + rename: (from: string, to: string): Promise => { vfs().renameSync(from, to) + return Promise.resolve() }, } } diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 96a63f859f..6f41fbc9ee 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -5,6 +5,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory */ import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts' +import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts' import { parseTar } from './tar.ts' import type { Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, @@ -773,3 +774,38 @@ export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryV } return vfs } + +/** + * Apply one ordered data overlay to an already mounted base image. + * + * Overlay entries may replace files only under the layout's data directories; + * module code, configuration, and the lowering manifest cannot be shadowed. + * Paths containing traversal segments are refused before normalization. Later + * overlays win for files, while file/directory type conflicts fail loud. + * @param image - Uncompressed ustar overlay archive. + * @param root - Virtual root shared with the base image. + * @param vfs - Mounted filesystem to update. + * @returns The same filesystem after applying the overlay. + */ +export function loadVfsOverlay(image: Uint8Array, root: string, vfs: MemoryVfs): MemoryVfs { + for (const entry of parseTar(image)) { + const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name + const path = relativeName.endsWith('/') ? relativeName.slice(0, -1) : relativeName + const segments = path.split('/') + if (path === '' || relativeName.startsWith(SEP) + || segments.some(segment => segment === '' || segment === '.' || segment === '..') + || !IMAGE_OVERLAY_DIRECTORIES.includes(segments[0] ?? '')) { + throw new Error(`webworker vfs: overlay entry must stay under ${IMAGE_OVERLAY_DIRECTORIES.join('/ or ')}, received "${entry.name}"`) + } + const target = join(root, path) + if (entry.directory) { + vfs.seedDirectory(target, { mode: entry.mode }) + continue + } + if (vfs.existsSync(target) && vfs.statSync(target).isDirectory()) { + throw new Error(`webworker vfs: overlay file cannot replace directory "${target}"`) + } + vfs.seed(target, entry.bytes, { mode: entry.mode }) + } + return vfs +} diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index 67d89b73fd..a04a2cec5a 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -33,12 +33,13 @@ export interface TunnelAbortFrame { /** Frames the worker accepts. */ /** - * First inbound frame: the image URL, the one input the worker assembly - * takes from outside. + * First inbound frame: the base image URL and ordered data overlays selected + * before the worker assembly starts. */ export interface TunnelInitFrame { readonly t: 'init' readonly image: string + readonly overlays: readonly string[] } /** Every frame the page sends the worker. */ @@ -142,7 +143,10 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof frame.image !== 'string') { throw new Error('webworker tunnel: init frame needs a string image url') } - return { t: 'init', image: frame.image } + if (!Array.isArray(frame.overlays) || frame.overlays.some(overlay => typeof overlay !== 'string')) { + throw new Error('webworker tunnel: init frame needs an array of string overlay urls') + } + return { t: 'init', image: frame.image, overlays: frame.overlays as string[] } } const id = frame.id if (typeof id !== 'string' && typeof id !== 'number') { diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index f73f1d2e5c..bbbf6a3900 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -29,7 +29,7 @@ import { installProcessGlobal } from './node/globals/process.ts' import type { RequestListener } from './transport/synthetic-http.ts' import { TunnelServer, type TunnelPort } from './transport/tunnel.ts' import { inflateImage, inflateImageStream } from './storage/image-gzip.ts' -import { loadVfsImage, MemoryVfs } from './storage/memory.ts' +import { loadVfsImage, loadVfsOverlay, MemoryVfs } from './storage/memory.ts' import { setActiveVfs } from './storage/active.ts' import { DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_HOME_DIRECTORY, IMAGE_MANIFEST_PATH, @@ -87,6 +87,8 @@ export interface WorkerHostOptions { readonly requestListener: () => Promise /** Image bytes, or the URL the worker fetches them from. */ readonly image: Uint8Array | string + /** Ordered data overlays applied after the base image and before boot. */ + readonly overlays?: readonly (Uint8Array | string)[] /** Virtual root; defaults to {@link DEFAULT_ROOT}. */ readonly root?: string /** Composed configuration inside the image; defaults to `/config/cordis.yml`. */ @@ -182,8 +184,12 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const home = join(root, IMAGE_HOME_DIRECTORY) installProcessGlobal({ cwd: root, env: { DSH_HOME: home, HOME: home, ...options.env } }) - const bytes = await readImage(options.image) + const [bytes, overlays] = await Promise.all([ + readImage(options.image), + Promise.all((options.overlays ?? []).map(readImage)), + ]) const mounted = loadVfsImage(bytes, root) + for (const overlay of overlays) loadVfsOverlay(overlay, root, mounted) // Belt and braces over the image's own empty-directory entries: a hand // -built image without them still boots. for (const directory of IMAGE_EMPTY_DIRECTORIES) { @@ -248,7 +254,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const shared = ctx.get('connection') !== undefined const handler = directFetchHandler(ctx, toFetchHandler(apiProxy)) const usage = loader.usage() - console.info(`webworker host: tree active (modules=${String(usage.modules)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), diff --git a/packages/experimental/webworker-runtime/src/worker.ts b/packages/experimental/webworker-runtime/src/worker.ts index f82c5fd1fb..f0912aef93 100644 --- a/packages/experimental/webworker-runtime/src/worker.ts +++ b/packages/experimental/webworker-runtime/src/worker.ts @@ -4,9 +4,9 @@ * listener; the assembly owns everything else (process global, VFS image, * Cordis tree, tunnel server). * - * The assembly needs the image location before it can exist, and it arrives in - * the tunnel's opening `init` frame — this bundle reads nothing from its own - * URL, so the deployment decides where both the bundle and the image live. + * The assembly needs the base image and selected overlays before it can exist; + * they arrive in the tunnel's opening `init` frame. This bundle reads nothing + * from its own URL, so the deployment decides where every archive lives. * Messages before `init` queue here; requests during boot queue inside the * host, which attaches its handler before its first await. */ @@ -52,12 +52,16 @@ self.addEventListener('message', (event: MessageEvent) => { if (typeof data.image !== 'string') { throw new Error('webworker: init frame needs a string image url') } + if (!Array.isArray(data.overlays) || data.overlays.some(overlay => typeof overlay !== 'string')) { + throw new Error('webworker: init frame needs an array of string overlay urls') + } const created = createWorkerHost({ staticModules: createNodeBuiltins(), staticModulePrefixes: REPLACED_PREFIXES, requestListener: whenRequestListener, alsCausality, image: data.image, + overlays: data.overlays as string[], }) host = created for (const queued of pending) { diff --git a/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts new file mode 100644 index 0000000000..6c629a2af8 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreviewFixtureManifest } from '../../src/fixture-manifest.ts' +import { choosePreviewSource } from '../../src/client/source-chooser.ts' + +const MANIFEST_URL = new URL('https://preview.test/preview/fixtures.json') + +const MANIFEST: PreviewFixtureManifest = { + version: 1, + defaultFixture: 'example', + fixtures: [{ + id: 'example', + label: 'Example & "quoted" \'single\'', + description: 'A deterministic example.', + overlays: ['fixtures/base.tar.gz', 'fixtures/tail.tar.gz'], + }], +} + +function setLocation(search = ''): void { + history.replaceState({}, '', `/preview.html${search}`) +} + +function installManifest(manifest: PreviewFixtureManifest = MANIFEST): ReturnType { + const fetch = vi.fn(async () => Response.json(manifest)) + vi.stubGlobal('fetch', fetch) + return fetch +} + +function submitChooser(): void { + const form = document.querySelector('[data-preview-source-card]') + if (form === null) throw new Error('test chooser form was not rendered') + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) +} + +describe('Preview source chooser', () => { + beforeEach(() => { + document.head.replaceChildren() + document.body.innerHTML = '
' + setLocation() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('bypasses the chooser and manifest for an explicit empty source', async () => { + setLocation('?preview-fixture=none') + const fetch = installManifest() + + await expect(choosePreviewSource(MANIFEST_URL)).resolves.toEqual([]) + expect(fetch).not.toHaveBeenCalled() + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() + }) + + it('bypasses the chooser and resolves an explicit built-in fixture', async () => { + document.body.replaceChildren() + setLocation('?preview-fixture=example') + const fetch = installManifest() + + await expect(choosePreviewSource(MANIFEST_URL)).resolves.toEqual([ + new URL('https://preview.test/preview/fixtures/base.tar.gz'), + new URL('https://preview.test/preview/fixtures/tail.tar.gz'), + ]) + expect(fetch).toHaveBeenCalledOnce() + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() + }) + + it.each(['', 'missing', 'webfs'])( + 'fails loud for the explicit unavailable source %j without opening the chooser', + async (source) => { + setLocation(`?preview-fixture=${source}`) + installManifest() + + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/unknown or interactive source/) + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() + }, + ) + + it('shows the chooser only when the query is absent and returns its default selection', async () => { + installManifest() + + const selected = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { + expect(document.querySelector('[data-preview-source-chooser]')).not.toBeNull() + }) + expect(document.querySelector('input[value="example"]')?.checked).toBe(true) + expect(document.querySelector('input[value="webfs"]')?.disabled).toBe(true) + expect(document.querySelector('[data-preview-source-card]')?.textContent) + .toContain(MANIFEST.fixtures[0]?.label) + expect(document.querySelector('[data-preview-source-card] script')).toBeNull() + + submitChooser() + + await expect(selected).resolves.toEqual([ + new URL('https://preview.test/preview/fixtures/base.tar.gz'), + new URL('https://preview.test/preview/fixtures/tail.tar.gz'), + ]) + expect(document.getElementById('root')?.childElementCount).toBe(0) + expect(document.querySelector('[data-preview-source-style]')).toBeNull() + }) + + it('selects the empty source when the manifest has no default', async () => { + installManifest({ ...MANIFEST, defaultFixture: null }) + + const selected = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { + expect(document.querySelector('input[value="none"]')?.checked).toBe(true) + }) + submitChooser() + + await expect(selected).resolves.toEqual([]) + }) + + it('reports manifest, mount, form, selection, and catalog failures', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('missing', { status: 404 }))) + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/returned 404/) + + installManifest() + document.body.replaceChildren() + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/missing #root/) + + document.body.innerHTML = '
' + const root = document.getElementById('root') + if (root === null) throw new Error('test root is missing') + vi.spyOn(root, 'querySelector').mockReturnValueOnce(null) + await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/form was not rendered/) + + document.head.replaceChildren() + document.body.innerHTML = '
' + const missingSelection = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { expect(document.querySelector('form')).not.toBeNull() }) + document.querySelectorAll('input[name="preview-source"]').forEach((input) => { + input.removeAttribute('name') + }) + submitChooser() + await expect(missingSelection).rejects.toThrow(/no source selected/) + + document.head.replaceChildren() + document.body.innerHTML = '
' + const unavailableSelection = choosePreviewSource(MANIFEST_URL) + await vi.waitFor(() => { expect(document.querySelector('form')).not.toBeNull() }) + const selected = document.querySelector('input:checked') + if (selected === null) throw new Error('test selection is missing') + selected.value = 'missing' + submitChooser() + await expect(unavailableSelection).rejects.toThrow(/unavailable source/) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts b/packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts new file mode 100644 index 0000000000..01b3d00e9a --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixture-manifest.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + parsePreviewFixtureManifest, PREVIEW_FIXTURE_MANIFEST_VERSION, +} from '../src/fixture-manifest.ts' + +describe('Preview fixture manifest', () => { + it('accepts a unique named fixture with ordered overlays', () => { + expect(parsePreviewFixtureManifest({ + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: 'example', + fixtures: [{ + id: 'example', + label: 'Example', + description: 'A deterministic example.', + overlays: ['fixtures/base.tar.gz', 'fixtures/tail.tar.gz'], + }], + })).toEqual({ + version: PREVIEW_FIXTURE_MANIFEST_VERSION, + defaultFixture: 'example', + fixtures: [{ + id: 'example', + label: 'Example', + description: 'A deterministic example.', + overlays: ['fixtures/base.tar.gz', 'fixtures/tail.tar.gz'], + }], + }) + }) + + it.each([ + [{ version: 2, defaultFixture: null, fixtures: [] }, /must use version/], + [{ version: 1, defaultFixture: 'missing', fixtures: [] }, /defaultFixture/], + [{ + version: 1, + defaultFixture: 'duplicate', + fixtures: [ + { id: 'duplicate', label: 'One', description: 'First.', overlays: ['one.tar.gz'] }, + { id: 'duplicate', label: 'Two', description: 'Second.', overlays: ['two.tar.gz'] }, + ], + }, /repeats id/], + [{ + version: 1, + defaultFixture: 'none', + fixtures: [{ id: 'none', label: 'None', description: 'Reserved.', overlays: ['none.tar.gz'] }], + }, /invalid fixture entry/], + ])('rejects malformed catalogs', (value, error) => { + expect(() => parsePreviewFixtureManifest(value)).toThrow(error) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl new file mode 100644 index 0000000000..291a18ba1e --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl @@ -0,0 +1,178 @@ +{"type":"session","version":0,"id":"preview-architecture-review","createdAt":1787472100000,"cwd":"/dsh/workspace","parentSession":"preview-showcase","seedLength":169,"origin":"subagent","delegationDepth":1,"agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472000000} +{"type":"user/message","data":{"id":"preview-user-01","role":"user","content":[{"type":"text","text":"History checkpoint 01: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472000001} +{"type":"session/title","data":{"title":"WebWorker Preview Showcase","messageSeqs":[],"source":{"kind":"user"}},"seq":2,"time":1787472000002} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472000003} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-assistant-01","role":"assistant","content":[{"type":"text","text":"Checkpoint 01 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472000004} +{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472000005} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472000006} +{"type":"turn/start","data":{"turn":2},"seq":7,"time":1787472000007} +{"type":"user/message","data":{"id":"preview-user-02","role":"user","content":[{"type":"text","text":"History checkpoint 02: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":8,"time":1787472000008} +{"type":"step/start","data":{"turn":2,"step":1},"seq":9,"time":1787472000009} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"id":"preview-assistant-02","role":"assistant","content":[{"type":"text","text":"Checkpoint 02 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":10,"time":1787472000010} +{"type":"step/end","data":{"turn":2,"step":1},"seq":11,"time":1787472000011} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":12,"time":1787472000012} +{"type":"turn/start","data":{"turn":3},"seq":13,"time":1787472000013} +{"type":"user/message","data":{"id":"preview-user-03","role":"user","content":[{"type":"text","text":"History checkpoint 03: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":14,"time":1787472000014} +{"type":"step/start","data":{"turn":3,"step":1},"seq":15,"time":1787472000015} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"id":"preview-assistant-03","role":"assistant","content":[{"type":"text","text":"Checkpoint 03 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":16,"time":1787472000016} +{"type":"step/end","data":{"turn":3,"step":1},"seq":17,"time":1787472000017} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":18,"time":1787472000018} +{"type":"turn/start","data":{"turn":4},"seq":19,"time":1787472000019} +{"type":"user/message","data":{"id":"preview-user-04","role":"user","content":[{"type":"text","text":"History checkpoint 04: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":20,"time":1787472000020} +{"type":"step/start","data":{"turn":4,"step":1},"seq":21,"time":1787472000021} +{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"id":"preview-assistant-04","role":"assistant","content":[{"type":"text","text":"Checkpoint 04 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":22,"time":1787472000022} +{"type":"step/end","data":{"turn":4,"step":1},"seq":23,"time":1787472000023} +{"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}},"seq":24,"time":1787472000024} +{"type":"turn/start","data":{"turn":5},"seq":25,"time":1787472000025} +{"type":"user/message","data":{"id":"preview-user-05","role":"user","content":[{"type":"text","text":"History checkpoint 05: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":26,"time":1787472000026} +{"type":"step/start","data":{"turn":5,"step":1},"seq":27,"time":1787472000027} +{"type":"assistant/message","data":{"turn":5,"step":1,"message":{"id":"preview-assistant-05","role":"assistant","content":[{"type":"text","text":"Checkpoint 05 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":28,"time":1787472000028} +{"type":"step/end","data":{"turn":5,"step":1},"seq":29,"time":1787472000029} +{"type":"turn/end","data":{"turn":5,"reason":{"kind":"completed"}},"seq":30,"time":1787472000030} +{"type":"turn/start","data":{"turn":6},"seq":31,"time":1787472000031} +{"type":"user/message","data":{"id":"preview-user-06","role":"user","content":[{"type":"text","text":"History checkpoint 06: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":32,"time":1787472000032} +{"type":"step/start","data":{"turn":6,"step":1},"seq":33,"time":1787472000033} +{"type":"assistant/message","data":{"turn":6,"step":1,"message":{"id":"preview-assistant-06","role":"assistant","content":[{"type":"text","text":"Checkpoint 06 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":34,"time":1787472000034} +{"type":"step/end","data":{"turn":6,"step":1},"seq":35,"time":1787472000035} +{"type":"turn/end","data":{"turn":6,"reason":{"kind":"completed"}},"seq":36,"time":1787472000036} +{"type":"turn/start","data":{"turn":7},"seq":37,"time":1787472000037} +{"type":"user/message","data":{"id":"preview-user-07","role":"user","content":[{"type":"text","text":"History checkpoint 07: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":38,"time":1787472000038} +{"type":"step/start","data":{"turn":7,"step":1},"seq":39,"time":1787472000039} +{"type":"assistant/message","data":{"turn":7,"step":1,"message":{"id":"preview-assistant-07","role":"assistant","content":[{"type":"text","text":"Checkpoint 07 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":40,"time":1787472000040} +{"type":"step/end","data":{"turn":7,"step":1},"seq":41,"time":1787472000041} +{"type":"turn/end","data":{"turn":7,"reason":{"kind":"completed"}},"seq":42,"time":1787472000042} +{"type":"turn/start","data":{"turn":8},"seq":43,"time":1787472000043} +{"type":"user/message","data":{"id":"preview-user-08","role":"user","content":[{"type":"text","text":"History checkpoint 08: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":44,"time":1787472000044} +{"type":"step/start","data":{"turn":8,"step":1},"seq":45,"time":1787472000045} +{"type":"assistant/message","data":{"turn":8,"step":1,"message":{"id":"preview-assistant-08","role":"assistant","content":[{"type":"text","text":"Checkpoint 08 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":46,"time":1787472000046} +{"type":"step/end","data":{"turn":8,"step":1},"seq":47,"time":1787472000047} +{"type":"turn/end","data":{"turn":8,"reason":{"kind":"completed"}},"seq":48,"time":1787472000048} +{"type":"turn/start","data":{"turn":9},"seq":49,"time":1787472000049} +{"type":"user/message","data":{"id":"preview-user-09","role":"user","content":[{"type":"text","text":"History checkpoint 09: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":50,"time":1787472000050} +{"type":"step/start","data":{"turn":9,"step":1},"seq":51,"time":1787472000051} +{"type":"assistant/message","data":{"turn":9,"step":1,"message":{"id":"preview-assistant-09","role":"assistant","content":[{"type":"text","text":"Checkpoint 09 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":52,"time":1787472000052} +{"type":"step/end","data":{"turn":9,"step":1},"seq":53,"time":1787472000053} +{"type":"turn/end","data":{"turn":9,"reason":{"kind":"completed"}},"seq":54,"time":1787472000054} +{"type":"turn/start","data":{"turn":10},"seq":55,"time":1787472000055} +{"type":"user/message","data":{"id":"preview-user-10","role":"user","content":[{"type":"text","text":"History checkpoint 10: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":56,"time":1787472000056} +{"type":"step/start","data":{"turn":10,"step":1},"seq":57,"time":1787472000057} +{"type":"assistant/message","data":{"turn":10,"step":1,"message":{"id":"preview-assistant-10","role":"assistant","content":[{"type":"text","text":"Checkpoint 10 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":58,"time":1787472000058} +{"type":"step/end","data":{"turn":10,"step":1},"seq":59,"time":1787472000059} +{"type":"turn/end","data":{"turn":10,"reason":{"kind":"completed"}},"seq":60,"time":1787472000060} +{"type":"turn/start","data":{"turn":11},"seq":61,"time":1787472000061} +{"type":"user/message","data":{"id":"preview-user-11","role":"user","content":[{"type":"text","text":"History checkpoint 11: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":62,"time":1787472000062} +{"type":"step/start","data":{"turn":11,"step":1},"seq":63,"time":1787472000063} +{"type":"assistant/message","data":{"turn":11,"step":1,"message":{"id":"preview-assistant-11","role":"assistant","content":[{"type":"text","text":"Checkpoint 11 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":64,"time":1787472000064} +{"type":"step/end","data":{"turn":11,"step":1},"seq":65,"time":1787472000065} +{"type":"turn/end","data":{"turn":11,"reason":{"kind":"completed"}},"seq":66,"time":1787472000066} +{"type":"turn/start","data":{"turn":12},"seq":67,"time":1787472000067} +{"type":"user/message","data":{"id":"preview-user-12","role":"user","content":[{"type":"text","text":"History checkpoint 12: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":68,"time":1787472000068} +{"type":"step/start","data":{"turn":12,"step":1},"seq":69,"time":1787472000069} +{"type":"assistant/message","data":{"turn":12,"step":1,"message":{"id":"preview-assistant-12","role":"assistant","content":[{"type":"text","text":"Checkpoint 12 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":70,"time":1787472000070} +{"type":"step/end","data":{"turn":12,"step":1},"seq":71,"time":1787472000071} +{"type":"turn/end","data":{"turn":12,"reason":{"kind":"completed"}},"seq":72,"time":1787472000072} +{"type":"turn/start","data":{"turn":13},"seq":73,"time":1787472000073} +{"type":"user/message","data":{"id":"preview-user-13","role":"user","content":[{"type":"text","text":"History checkpoint 13: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":74,"time":1787472000074} +{"type":"step/start","data":{"turn":13,"step":1},"seq":75,"time":1787472000075} +{"type":"assistant/message","data":{"turn":13,"step":1,"message":{"id":"preview-assistant-13","role":"assistant","content":[{"type":"text","text":"Checkpoint 13 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":76,"time":1787472000076} +{"type":"step/end","data":{"turn":13,"step":1},"seq":77,"time":1787472000077} +{"type":"turn/end","data":{"turn":13,"reason":{"kind":"completed"}},"seq":78,"time":1787472000078} +{"type":"turn/start","data":{"turn":14},"seq":79,"time":1787472000079} +{"type":"user/message","data":{"id":"preview-user-14","role":"user","content":[{"type":"text","text":"History checkpoint 14: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":80,"time":1787472000080} +{"type":"step/start","data":{"turn":14,"step":1},"seq":81,"time":1787472000081} +{"type":"assistant/message","data":{"turn":14,"step":1,"message":{"id":"preview-assistant-14","role":"assistant","content":[{"type":"text","text":"Checkpoint 14 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":82,"time":1787472000082} +{"type":"step/end","data":{"turn":14,"step":1},"seq":83,"time":1787472000083} +{"type":"turn/end","data":{"turn":14,"reason":{"kind":"completed"}},"seq":84,"time":1787472000084} +{"type":"turn/start","data":{"turn":15},"seq":85,"time":1787472000085} +{"type":"user/message","data":{"id":"preview-user-15","role":"user","content":[{"type":"text","text":"History checkpoint 15: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":86,"time":1787472000086} +{"type":"step/start","data":{"turn":15,"step":1},"seq":87,"time":1787472000087} +{"type":"assistant/message","data":{"turn":15,"step":1,"message":{"id":"preview-assistant-15","role":"assistant","content":[{"type":"text","text":"Checkpoint 15 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":88,"time":1787472000088} +{"type":"step/end","data":{"turn":15,"step":1},"seq":89,"time":1787472000089} +{"type":"turn/end","data":{"turn":15,"reason":{"kind":"completed"}},"seq":90,"time":1787472000090} +{"type":"turn/start","data":{"turn":16},"seq":91,"time":1787472000091} +{"type":"user/message","data":{"id":"preview-user-16","role":"user","content":[{"type":"text","text":"History checkpoint 16: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":92,"time":1787472000092} +{"type":"step/start","data":{"turn":16,"step":1},"seq":93,"time":1787472000093} +{"type":"assistant/message","data":{"turn":16,"step":1,"message":{"id":"preview-assistant-16","role":"assistant","content":[{"type":"text","text":"Checkpoint 16 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":94,"time":1787472000094} +{"type":"step/end","data":{"turn":16,"step":1},"seq":95,"time":1787472000095} +{"type":"turn/end","data":{"turn":16,"reason":{"kind":"completed"}},"seq":96,"time":1787472000096} +{"type":"turn/start","data":{"turn":17},"seq":97,"time":1787472000097} +{"type":"user/message","data":{"id":"preview-user-17","role":"user","content":[{"type":"text","text":"History checkpoint 17: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":98,"time":1787472000098} +{"type":"step/start","data":{"turn":17,"step":1},"seq":99,"time":1787472000099} +{"type":"assistant/message","data":{"turn":17,"step":1,"message":{"id":"preview-assistant-17","role":"assistant","content":[{"type":"text","text":"Checkpoint 17 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":100,"time":1787472000100} +{"type":"step/end","data":{"turn":17,"step":1},"seq":101,"time":1787472000101} +{"type":"turn/end","data":{"turn":17,"reason":{"kind":"completed"}},"seq":102,"time":1787472000102} +{"type":"turn/start","data":{"turn":18},"seq":103,"time":1787472000103} +{"type":"user/message","data":{"id":"preview-user-18","role":"user","content":[{"type":"text","text":"History checkpoint 18: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":104,"time":1787472000104} +{"type":"step/start","data":{"turn":18,"step":1},"seq":105,"time":1787472000105} +{"type":"assistant/message","data":{"turn":18,"step":1,"message":{"id":"preview-assistant-18","role":"assistant","content":[{"type":"text","text":"Checkpoint 18 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":106,"time":1787472000106} +{"type":"step/end","data":{"turn":18,"step":1},"seq":107,"time":1787472000107} +{"type":"turn/end","data":{"turn":18,"reason":{"kind":"completed"}},"seq":108,"time":1787472000108} +{"type":"turn/start","data":{"turn":19},"seq":109,"time":1787472000109} +{"type":"user/message","data":{"id":"preview-user-19","role":"user","content":[{"type":"text","text":"History checkpoint 19: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":110,"time":1787472000110} +{"type":"step/start","data":{"turn":19,"step":1},"seq":111,"time":1787472000111} +{"type":"assistant/message","data":{"turn":19,"step":1,"message":{"id":"preview-assistant-19","role":"assistant","content":[{"type":"text","text":"Checkpoint 19 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":112,"time":1787472000112} +{"type":"step/end","data":{"turn":19,"step":1},"seq":113,"time":1787472000113} +{"type":"turn/end","data":{"turn":19,"reason":{"kind":"completed"}},"seq":114,"time":1787472000114} +{"type":"turn/start","data":{"turn":20},"seq":115,"time":1787472000115} +{"type":"user/message","data":{"id":"preview-user-20","role":"user","content":[{"type":"text","text":"History checkpoint 20: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":116,"time":1787472000116} +{"type":"step/start","data":{"turn":20,"step":1},"seq":117,"time":1787472000117} +{"type":"assistant/message","data":{"turn":20,"step":1,"message":{"id":"preview-assistant-20","role":"assistant","content":[{"type":"text","text":"Checkpoint 20 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":118,"time":1787472000118} +{"type":"step/end","data":{"turn":20,"step":1},"seq":119,"time":1787472000119} +{"type":"turn/end","data":{"turn":20,"reason":{"kind":"completed"}},"seq":120,"time":1787472000120} +{"type":"turn/start","data":{"turn":21},"seq":121,"time":1787472000121} +{"type":"user/message","data":{"id":"preview-user-21","role":"user","content":[{"type":"text","text":"History checkpoint 21: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":122,"time":1787472000122} +{"type":"step/start","data":{"turn":21,"step":1},"seq":123,"time":1787472000123} +{"type":"assistant/message","data":{"turn":21,"step":1,"message":{"id":"preview-assistant-21","role":"assistant","content":[{"type":"text","text":"Checkpoint 21 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":124,"time":1787472000124} +{"type":"step/end","data":{"turn":21,"step":1},"seq":125,"time":1787472000125} +{"type":"turn/end","data":{"turn":21,"reason":{"kind":"completed"}},"seq":126,"time":1787472000126} +{"type":"turn/start","data":{"turn":22},"seq":127,"time":1787472000127} +{"type":"user/message","data":{"id":"preview-user-22","role":"user","content":[{"type":"text","text":"History checkpoint 22: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":128,"time":1787472000128} +{"type":"step/start","data":{"turn":22,"step":1},"seq":129,"time":1787472000129} +{"type":"assistant/message","data":{"turn":22,"step":1,"message":{"id":"preview-assistant-22","role":"assistant","content":[{"type":"text","text":"Checkpoint 22 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":130,"time":1787472000130} +{"type":"step/end","data":{"turn":22,"step":1},"seq":131,"time":1787472000131} +{"type":"turn/end","data":{"turn":22,"reason":{"kind":"completed"}},"seq":132,"time":1787472000132} +{"type":"turn/start","data":{"turn":23},"seq":133,"time":1787472000133} +{"type":"user/message","data":{"id":"preview-user-23","role":"user","content":[{"type":"text","text":"History checkpoint 23: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":134,"time":1787472000134} +{"type":"step/start","data":{"turn":23,"step":1},"seq":135,"time":1787472000135} +{"type":"assistant/message","data":{"turn":23,"step":1,"message":{"id":"preview-assistant-23","role":"assistant","content":[{"type":"text","text":"Checkpoint 23 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":136,"time":1787472000136} +{"type":"step/end","data":{"turn":23,"step":1},"seq":137,"time":1787472000137} +{"type":"turn/end","data":{"turn":23,"reason":{"kind":"completed"}},"seq":138,"time":1787472000138} +{"type":"turn/start","data":{"turn":24},"seq":139,"time":1787472000139} +{"type":"user/message","data":{"id":"preview-user-24","role":"user","content":[{"type":"text","text":"History checkpoint 24: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":140,"time":1787472000140} +{"type":"step/start","data":{"turn":24,"step":1},"seq":141,"time":1787472000141} +{"type":"assistant/message","data":{"turn":24,"step":1,"message":{"id":"preview-assistant-24","role":"assistant","content":[{"type":"text","text":"Checkpoint 24 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":142,"time":1787472000142} +{"type":"step/end","data":{"turn":24,"step":1},"seq":143,"time":1787472000143} +{"type":"turn/end","data":{"turn":24,"reason":{"kind":"completed"}},"seq":144,"time":1787472000144} +{"type":"turn/start","data":{"turn":25},"seq":145,"time":1787472000145} +{"type":"user/message","data":{"id":"preview-user-25","role":"user","content":[{"type":"text","text":"History checkpoint 25: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":146,"time":1787472000146} +{"type":"step/start","data":{"turn":25,"step":1},"seq":147,"time":1787472000147} +{"type":"assistant/message","data":{"turn":25,"step":1,"message":{"id":"preview-assistant-25","role":"assistant","content":[{"type":"text","text":"Checkpoint 25 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":148,"time":1787472000148} +{"type":"step/end","data":{"turn":25,"step":1},"seq":149,"time":1787472000149} +{"type":"turn/end","data":{"turn":25,"reason":{"kind":"completed"}},"seq":150,"time":1787472000150} +{"type":"turn/start","data":{"turn":26},"seq":151,"time":1787472000151} +{"type":"user/message","data":{"id":"preview-user-26","role":"user","content":[{"type":"text","text":"History checkpoint 26: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":152,"time":1787472000152} +{"type":"step/start","data":{"turn":26,"step":1},"seq":153,"time":1787472000153} +{"type":"assistant/message","data":{"turn":26,"step":1,"message":{"id":"preview-assistant-26","role":"assistant","content":[{"type":"text","text":"Checkpoint 26 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":154,"time":1787472000154} +{"type":"step/end","data":{"turn":26,"step":1},"seq":155,"time":1787472000155} +{"type":"turn/end","data":{"turn":26,"reason":{"kind":"completed"}},"seq":156,"time":1787472000156} +{"type":"turn/start","data":{"turn":27},"seq":157,"time":1787472000157} +{"type":"user/message","data":{"id":"preview-user-27","role":"user","content":[{"type":"text","text":"History checkpoint 27: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":158,"time":1787472000158} +{"type":"step/start","data":{"turn":27,"step":1},"seq":159,"time":1787472000159} +{"type":"assistant/message","data":{"turn":27,"step":1,"message":{"id":"preview-assistant-27","role":"assistant","content":[{"type":"text","text":"Checkpoint 27 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":160,"time":1787472000160} +{"type":"step/end","data":{"turn":27,"step":1},"seq":161,"time":1787472000161} +{"type":"turn/end","data":{"turn":27,"reason":{"kind":"completed"}},"seq":162,"time":1787472000162} +{"type":"turn/start","data":{"turn":28},"seq":163,"time":1787472000163} +{"type":"user/message","data":{"id":"preview-user-28","role":"user","content":[{"type":"text","text":"History checkpoint 28: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":164,"time":1787472000164} +{"type":"step/start","data":{"turn":28,"step":1},"seq":165,"time":1787472000165} +{"type":"assistant/message","data":{"turn":28,"step":1,"message":{"id":"preview-assistant-28","role":"assistant","content":[{"type":"text","text":"Checkpoint 28 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":166,"time":1787472000166} +{"type":"step/end","data":{"turn":28,"step":1},"seq":167,"time":1787472000167} +{"type":"turn/end","data":{"turn":28,"reason":{"kind":"completed"}},"seq":168,"time":1787472000168} +{"type":"session/end-seed","data":{},"seq":169,"time":1787472100000} +{"type":"turn/start","data":{"turn":29},"seq":170,"time":1787472100001} +{"type":"user/message","data":{"id":"preview-review-user","role":"user","content":[{"type":"text","text":"Review whether the preview fixture is isolated from future WebFS data."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":171,"time":1787472100002} +{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Review preview architecture"},"seq":172,"time":1787472100003} +{"type":"step/start","data":{"turn":29,"step":1},"seq":173,"time":1787472100004} +{"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-review-assistant","role":"assistant","content":[{"type":"text","text":"The bundled fixture is static image content; future WebFS state remains user-owned."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":174,"time":1787472100005} +{"type":"step/end","data":{"turn":29,"step":1},"seq":175,"time":1787472100006} +{"type":"turn/end","data":{"turn":29,"reason":{"kind":"completed"}},"seq":176,"time":1787472100007} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl new file mode 100644 index 0000000000..6353e854c4 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl @@ -0,0 +1,8 @@ +{"type":"session","version":0,"id":"preview-follow-up-builder","createdAt":1787472200000,"cwd":"/dsh/workspace","parentSession":"preview-showcase","origin":"subagent","delegationDepth":1,"agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472200000} +{"type":"user/message","data":{"id":"preview-builder-user","role":"user","content":[{"type":"text","text":"Check that the Preview workspace can support follow-up tasks."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472200001} +{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Continue preview verification"},"seq":2,"time":1787472200002} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472200003} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-builder-assistant","role":"assistant","content":[{"type":"text","text":"This child is continuable and ready for another verification turn."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472200004} +{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472200005} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472200006} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl new file mode 100644 index 0000000000..7b15d63fb3 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-showcase/session.jsonl @@ -0,0 +1,200 @@ +{"type":"session","version":0,"id":"preview-showcase","createdAt":1787472000000,"cwd":"/dsh/workspace","delegationDepth":0,"agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472000000} +{"type":"user/message","data":{"id":"preview-user-01","role":"user","content":[{"type":"text","text":"History checkpoint 01: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472000001} +{"type":"session/title","data":{"title":"WebWorker Preview Showcase","messageSeqs":[],"source":{"kind":"user"}},"seq":2,"time":1787472000002} +{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472000003} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-assistant-01","role":"assistant","content":[{"type":"text","text":"Checkpoint 01 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472000004} +{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472000005} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472000006} +{"type":"turn/start","data":{"turn":2},"seq":7,"time":1787472000007} +{"type":"user/message","data":{"id":"preview-user-02","role":"user","content":[{"type":"text","text":"History checkpoint 02: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":8,"time":1787472000008} +{"type":"step/start","data":{"turn":2,"step":1},"seq":9,"time":1787472000009} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"id":"preview-assistant-02","role":"assistant","content":[{"type":"text","text":"Checkpoint 02 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":10,"time":1787472000010} +{"type":"step/end","data":{"turn":2,"step":1},"seq":11,"time":1787472000011} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":12,"time":1787472000012} +{"type":"turn/start","data":{"turn":3},"seq":13,"time":1787472000013} +{"type":"user/message","data":{"id":"preview-user-03","role":"user","content":[{"type":"text","text":"History checkpoint 03: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":14,"time":1787472000014} +{"type":"step/start","data":{"turn":3,"step":1},"seq":15,"time":1787472000015} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"id":"preview-assistant-03","role":"assistant","content":[{"type":"text","text":"Checkpoint 03 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":16,"time":1787472000016} +{"type":"step/end","data":{"turn":3,"step":1},"seq":17,"time":1787472000017} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":18,"time":1787472000018} +{"type":"turn/start","data":{"turn":4},"seq":19,"time":1787472000019} +{"type":"user/message","data":{"id":"preview-user-04","role":"user","content":[{"type":"text","text":"History checkpoint 04: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":20,"time":1787472000020} +{"type":"step/start","data":{"turn":4,"step":1},"seq":21,"time":1787472000021} +{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"id":"preview-assistant-04","role":"assistant","content":[{"type":"text","text":"Checkpoint 04 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":22,"time":1787472000022} +{"type":"step/end","data":{"turn":4,"step":1},"seq":23,"time":1787472000023} +{"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}},"seq":24,"time":1787472000024} +{"type":"turn/start","data":{"turn":5},"seq":25,"time":1787472000025} +{"type":"user/message","data":{"id":"preview-user-05","role":"user","content":[{"type":"text","text":"History checkpoint 05: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":26,"time":1787472000026} +{"type":"step/start","data":{"turn":5,"step":1},"seq":27,"time":1787472000027} +{"type":"assistant/message","data":{"turn":5,"step":1,"message":{"id":"preview-assistant-05","role":"assistant","content":[{"type":"text","text":"Checkpoint 05 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":28,"time":1787472000028} +{"type":"step/end","data":{"turn":5,"step":1},"seq":29,"time":1787472000029} +{"type":"turn/end","data":{"turn":5,"reason":{"kind":"completed"}},"seq":30,"time":1787472000030} +{"type":"turn/start","data":{"turn":6},"seq":31,"time":1787472000031} +{"type":"user/message","data":{"id":"preview-user-06","role":"user","content":[{"type":"text","text":"History checkpoint 06: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":32,"time":1787472000032} +{"type":"step/start","data":{"turn":6,"step":1},"seq":33,"time":1787472000033} +{"type":"assistant/message","data":{"turn":6,"step":1,"message":{"id":"preview-assistant-06","role":"assistant","content":[{"type":"text","text":"Checkpoint 06 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":34,"time":1787472000034} +{"type":"step/end","data":{"turn":6,"step":1},"seq":35,"time":1787472000035} +{"type":"turn/end","data":{"turn":6,"reason":{"kind":"completed"}},"seq":36,"time":1787472000036} +{"type":"turn/start","data":{"turn":7},"seq":37,"time":1787472000037} +{"type":"user/message","data":{"id":"preview-user-07","role":"user","content":[{"type":"text","text":"History checkpoint 07: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":38,"time":1787472000038} +{"type":"step/start","data":{"turn":7,"step":1},"seq":39,"time":1787472000039} +{"type":"assistant/message","data":{"turn":7,"step":1,"message":{"id":"preview-assistant-07","role":"assistant","content":[{"type":"text","text":"Checkpoint 07 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":40,"time":1787472000040} +{"type":"step/end","data":{"turn":7,"step":1},"seq":41,"time":1787472000041} +{"type":"turn/end","data":{"turn":7,"reason":{"kind":"completed"}},"seq":42,"time":1787472000042} +{"type":"turn/start","data":{"turn":8},"seq":43,"time":1787472000043} +{"type":"user/message","data":{"id":"preview-user-08","role":"user","content":[{"type":"text","text":"History checkpoint 08: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":44,"time":1787472000044} +{"type":"step/start","data":{"turn":8,"step":1},"seq":45,"time":1787472000045} +{"type":"assistant/message","data":{"turn":8,"step":1,"message":{"id":"preview-assistant-08","role":"assistant","content":[{"type":"text","text":"Checkpoint 08 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":46,"time":1787472000046} +{"type":"step/end","data":{"turn":8,"step":1},"seq":47,"time":1787472000047} +{"type":"turn/end","data":{"turn":8,"reason":{"kind":"completed"}},"seq":48,"time":1787472000048} +{"type":"turn/start","data":{"turn":9},"seq":49,"time":1787472000049} +{"type":"user/message","data":{"id":"preview-user-09","role":"user","content":[{"type":"text","text":"History checkpoint 09: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":50,"time":1787472000050} +{"type":"step/start","data":{"turn":9,"step":1},"seq":51,"time":1787472000051} +{"type":"assistant/message","data":{"turn":9,"step":1,"message":{"id":"preview-assistant-09","role":"assistant","content":[{"type":"text","text":"Checkpoint 09 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":52,"time":1787472000052} +{"type":"step/end","data":{"turn":9,"step":1},"seq":53,"time":1787472000053} +{"type":"turn/end","data":{"turn":9,"reason":{"kind":"completed"}},"seq":54,"time":1787472000054} +{"type":"turn/start","data":{"turn":10},"seq":55,"time":1787472000055} +{"type":"user/message","data":{"id":"preview-user-10","role":"user","content":[{"type":"text","text":"History checkpoint 10: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":56,"time":1787472000056} +{"type":"step/start","data":{"turn":10,"step":1},"seq":57,"time":1787472000057} +{"type":"assistant/message","data":{"turn":10,"step":1,"message":{"id":"preview-assistant-10","role":"assistant","content":[{"type":"text","text":"Checkpoint 10 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":58,"time":1787472000058} +{"type":"step/end","data":{"turn":10,"step":1},"seq":59,"time":1787472000059} +{"type":"turn/end","data":{"turn":10,"reason":{"kind":"completed"}},"seq":60,"time":1787472000060} +{"type":"turn/start","data":{"turn":11},"seq":61,"time":1787472000061} +{"type":"user/message","data":{"id":"preview-user-11","role":"user","content":[{"type":"text","text":"History checkpoint 11: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":62,"time":1787472000062} +{"type":"step/start","data":{"turn":11,"step":1},"seq":63,"time":1787472000063} +{"type":"assistant/message","data":{"turn":11,"step":1,"message":{"id":"preview-assistant-11","role":"assistant","content":[{"type":"text","text":"Checkpoint 11 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":64,"time":1787472000064} +{"type":"step/end","data":{"turn":11,"step":1},"seq":65,"time":1787472000065} +{"type":"turn/end","data":{"turn":11,"reason":{"kind":"completed"}},"seq":66,"time":1787472000066} +{"type":"turn/start","data":{"turn":12},"seq":67,"time":1787472000067} +{"type":"user/message","data":{"id":"preview-user-12","role":"user","content":[{"type":"text","text":"History checkpoint 12: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":68,"time":1787472000068} +{"type":"step/start","data":{"turn":12,"step":1},"seq":69,"time":1787472000069} +{"type":"assistant/message","data":{"turn":12,"step":1,"message":{"id":"preview-assistant-12","role":"assistant","content":[{"type":"text","text":"Checkpoint 12 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":70,"time":1787472000070} +{"type":"step/end","data":{"turn":12,"step":1},"seq":71,"time":1787472000071} +{"type":"turn/end","data":{"turn":12,"reason":{"kind":"completed"}},"seq":72,"time":1787472000072} +{"type":"turn/start","data":{"turn":13},"seq":73,"time":1787472000073} +{"type":"user/message","data":{"id":"preview-user-13","role":"user","content":[{"type":"text","text":"History checkpoint 13: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":74,"time":1787472000074} +{"type":"step/start","data":{"turn":13,"step":1},"seq":75,"time":1787472000075} +{"type":"assistant/message","data":{"turn":13,"step":1,"message":{"id":"preview-assistant-13","role":"assistant","content":[{"type":"text","text":"Checkpoint 13 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":76,"time":1787472000076} +{"type":"step/end","data":{"turn":13,"step":1},"seq":77,"time":1787472000077} +{"type":"turn/end","data":{"turn":13,"reason":{"kind":"completed"}},"seq":78,"time":1787472000078} +{"type":"turn/start","data":{"turn":14},"seq":79,"time":1787472000079} +{"type":"user/message","data":{"id":"preview-user-14","role":"user","content":[{"type":"text","text":"History checkpoint 14: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":80,"time":1787472000080} +{"type":"step/start","data":{"turn":14,"step":1},"seq":81,"time":1787472000081} +{"type":"assistant/message","data":{"turn":14,"step":1,"message":{"id":"preview-assistant-14","role":"assistant","content":[{"type":"text","text":"Checkpoint 14 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":82,"time":1787472000082} +{"type":"step/end","data":{"turn":14,"step":1},"seq":83,"time":1787472000083} +{"type":"turn/end","data":{"turn":14,"reason":{"kind":"completed"}},"seq":84,"time":1787472000084} +{"type":"turn/start","data":{"turn":15},"seq":85,"time":1787472000085} +{"type":"user/message","data":{"id":"preview-user-15","role":"user","content":[{"type":"text","text":"History checkpoint 15: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":86,"time":1787472000086} +{"type":"step/start","data":{"turn":15,"step":1},"seq":87,"time":1787472000087} +{"type":"assistant/message","data":{"turn":15,"step":1,"message":{"id":"preview-assistant-15","role":"assistant","content":[{"type":"text","text":"Checkpoint 15 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":88,"time":1787472000088} +{"type":"step/end","data":{"turn":15,"step":1},"seq":89,"time":1787472000089} +{"type":"turn/end","data":{"turn":15,"reason":{"kind":"completed"}},"seq":90,"time":1787472000090} +{"type":"turn/start","data":{"turn":16},"seq":91,"time":1787472000091} +{"type":"user/message","data":{"id":"preview-user-16","role":"user","content":[{"type":"text","text":"History checkpoint 16: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":92,"time":1787472000092} +{"type":"step/start","data":{"turn":16,"step":1},"seq":93,"time":1787472000093} +{"type":"assistant/message","data":{"turn":16,"step":1,"message":{"id":"preview-assistant-16","role":"assistant","content":[{"type":"text","text":"Checkpoint 16 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":94,"time":1787472000094} +{"type":"step/end","data":{"turn":16,"step":1},"seq":95,"time":1787472000095} +{"type":"turn/end","data":{"turn":16,"reason":{"kind":"completed"}},"seq":96,"time":1787472000096} +{"type":"turn/start","data":{"turn":17},"seq":97,"time":1787472000097} +{"type":"user/message","data":{"id":"preview-user-17","role":"user","content":[{"type":"text","text":"History checkpoint 17: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":98,"time":1787472000098} +{"type":"step/start","data":{"turn":17,"step":1},"seq":99,"time":1787472000099} +{"type":"assistant/message","data":{"turn":17,"step":1,"message":{"id":"preview-assistant-17","role":"assistant","content":[{"type":"text","text":"Checkpoint 17 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":100,"time":1787472000100} +{"type":"step/end","data":{"turn":17,"step":1},"seq":101,"time":1787472000101} +{"type":"turn/end","data":{"turn":17,"reason":{"kind":"completed"}},"seq":102,"time":1787472000102} +{"type":"turn/start","data":{"turn":18},"seq":103,"time":1787472000103} +{"type":"user/message","data":{"id":"preview-user-18","role":"user","content":[{"type":"text","text":"History checkpoint 18: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":104,"time":1787472000104} +{"type":"step/start","data":{"turn":18,"step":1},"seq":105,"time":1787472000105} +{"type":"assistant/message","data":{"turn":18,"step":1,"message":{"id":"preview-assistant-18","role":"assistant","content":[{"type":"text","text":"Checkpoint 18 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":106,"time":1787472000106} +{"type":"step/end","data":{"turn":18,"step":1},"seq":107,"time":1787472000107} +{"type":"turn/end","data":{"turn":18,"reason":{"kind":"completed"}},"seq":108,"time":1787472000108} +{"type":"turn/start","data":{"turn":19},"seq":109,"time":1787472000109} +{"type":"user/message","data":{"id":"preview-user-19","role":"user","content":[{"type":"text","text":"History checkpoint 19: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":110,"time":1787472000110} +{"type":"step/start","data":{"turn":19,"step":1},"seq":111,"time":1787472000111} +{"type":"assistant/message","data":{"turn":19,"step":1,"message":{"id":"preview-assistant-19","role":"assistant","content":[{"type":"text","text":"Checkpoint 19 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":112,"time":1787472000112} +{"type":"step/end","data":{"turn":19,"step":1},"seq":113,"time":1787472000113} +{"type":"turn/end","data":{"turn":19,"reason":{"kind":"completed"}},"seq":114,"time":1787472000114} +{"type":"turn/start","data":{"turn":20},"seq":115,"time":1787472000115} +{"type":"user/message","data":{"id":"preview-user-20","role":"user","content":[{"type":"text","text":"History checkpoint 20: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":116,"time":1787472000116} +{"type":"step/start","data":{"turn":20,"step":1},"seq":117,"time":1787472000117} +{"type":"assistant/message","data":{"turn":20,"step":1,"message":{"id":"preview-assistant-20","role":"assistant","content":[{"type":"text","text":"Checkpoint 20 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":118,"time":1787472000118} +{"type":"step/end","data":{"turn":20,"step":1},"seq":119,"time":1787472000119} +{"type":"turn/end","data":{"turn":20,"reason":{"kind":"completed"}},"seq":120,"time":1787472000120} +{"type":"turn/start","data":{"turn":21},"seq":121,"time":1787472000121} +{"type":"user/message","data":{"id":"preview-user-21","role":"user","content":[{"type":"text","text":"History checkpoint 21: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":122,"time":1787472000122} +{"type":"step/start","data":{"turn":21,"step":1},"seq":123,"time":1787472000123} +{"type":"assistant/message","data":{"turn":21,"step":1,"message":{"id":"preview-assistant-21","role":"assistant","content":[{"type":"text","text":"Checkpoint 21 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":124,"time":1787472000124} +{"type":"step/end","data":{"turn":21,"step":1},"seq":125,"time":1787472000125} +{"type":"turn/end","data":{"turn":21,"reason":{"kind":"completed"}},"seq":126,"time":1787472000126} +{"type":"turn/start","data":{"turn":22},"seq":127,"time":1787472000127} +{"type":"user/message","data":{"id":"preview-user-22","role":"user","content":[{"type":"text","text":"History checkpoint 22: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":128,"time":1787472000128} +{"type":"step/start","data":{"turn":22,"step":1},"seq":129,"time":1787472000129} +{"type":"assistant/message","data":{"turn":22,"step":1,"message":{"id":"preview-assistant-22","role":"assistant","content":[{"type":"text","text":"Checkpoint 22 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":130,"time":1787472000130} +{"type":"step/end","data":{"turn":22,"step":1},"seq":131,"time":1787472000131} +{"type":"turn/end","data":{"turn":22,"reason":{"kind":"completed"}},"seq":132,"time":1787472000132} +{"type":"turn/start","data":{"turn":23},"seq":133,"time":1787472000133} +{"type":"user/message","data":{"id":"preview-user-23","role":"user","content":[{"type":"text","text":"History checkpoint 23: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":134,"time":1787472000134} +{"type":"step/start","data":{"turn":23,"step":1},"seq":135,"time":1787472000135} +{"type":"assistant/message","data":{"turn":23,"step":1,"message":{"id":"preview-assistant-23","role":"assistant","content":[{"type":"text","text":"Checkpoint 23 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":136,"time":1787472000136} +{"type":"step/end","data":{"turn":23,"step":1},"seq":137,"time":1787472000137} +{"type":"turn/end","data":{"turn":23,"reason":{"kind":"completed"}},"seq":138,"time":1787472000138} +{"type":"turn/start","data":{"turn":24},"seq":139,"time":1787472000139} +{"type":"user/message","data":{"id":"preview-user-24","role":"user","content":[{"type":"text","text":"History checkpoint 24: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":140,"time":1787472000140} +{"type":"step/start","data":{"turn":24,"step":1},"seq":141,"time":1787472000141} +{"type":"assistant/message","data":{"turn":24,"step":1,"message":{"id":"preview-assistant-24","role":"assistant","content":[{"type":"text","text":"Checkpoint 24 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":142,"time":1787472000142} +{"type":"step/end","data":{"turn":24,"step":1},"seq":143,"time":1787472000143} +{"type":"turn/end","data":{"turn":24,"reason":{"kind":"completed"}},"seq":144,"time":1787472000144} +{"type":"turn/start","data":{"turn":25},"seq":145,"time":1787472000145} +{"type":"user/message","data":{"id":"preview-user-25","role":"user","content":[{"type":"text","text":"History checkpoint 25: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":146,"time":1787472000146} +{"type":"step/start","data":{"turn":25,"step":1},"seq":147,"time":1787472000147} +{"type":"assistant/message","data":{"turn":25,"step":1,"message":{"id":"preview-assistant-25","role":"assistant","content":[{"type":"text","text":"Checkpoint 25 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":148,"time":1787472000148} +{"type":"step/end","data":{"turn":25,"step":1},"seq":149,"time":1787472000149} +{"type":"turn/end","data":{"turn":25,"reason":{"kind":"completed"}},"seq":150,"time":1787472000150} +{"type":"turn/start","data":{"turn":26},"seq":151,"time":1787472000151} +{"type":"user/message","data":{"id":"preview-user-26","role":"user","content":[{"type":"text","text":"History checkpoint 26: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":152,"time":1787472000152} +{"type":"step/start","data":{"turn":26,"step":1},"seq":153,"time":1787472000153} +{"type":"assistant/message","data":{"turn":26,"step":1,"message":{"id":"preview-assistant-26","role":"assistant","content":[{"type":"text","text":"Checkpoint 26 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":154,"time":1787472000154} +{"type":"step/end","data":{"turn":26,"step":1},"seq":155,"time":1787472000155} +{"type":"turn/end","data":{"turn":26,"reason":{"kind":"completed"}},"seq":156,"time":1787472000156} +{"type":"turn/start","data":{"turn":27},"seq":157,"time":1787472000157} +{"type":"user/message","data":{"id":"preview-user-27","role":"user","content":[{"type":"text","text":"History checkpoint 27: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":158,"time":1787472000158} +{"type":"step/start","data":{"turn":27,"step":1},"seq":159,"time":1787472000159} +{"type":"assistant/message","data":{"turn":27,"step":1,"message":{"id":"preview-assistant-27","role":"assistant","content":[{"type":"text","text":"Checkpoint 27 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":160,"time":1787472000160} +{"type":"step/end","data":{"turn":27,"step":1},"seq":161,"time":1787472000161} +{"type":"turn/end","data":{"turn":27,"reason":{"kind":"completed"}},"seq":162,"time":1787472000162} +{"type":"turn/start","data":{"turn":28},"seq":163,"time":1787472000163} +{"type":"user/message","data":{"id":"preview-user-28","role":"user","content":[{"type":"text","text":"History checkpoint 28: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":164,"time":1787472000164} +{"type":"step/start","data":{"turn":28,"step":1},"seq":165,"time":1787472000165} +{"type":"assistant/message","data":{"turn":28,"step":1,"message":{"id":"preview-assistant-28","role":"assistant","content":[{"type":"text","text":"Checkpoint 28 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":166,"time":1787472000166} +{"type":"step/end","data":{"turn":28,"step":1},"seq":167,"time":1787472000167} +{"type":"turn/end","data":{"turn":28,"reason":{"kind":"completed"}},"seq":168,"time":1787472000168} +{"type":"turn/start","data":{"turn":29},"seq":169,"time":1787472000169} +{"type":"user/message","data":{"id":"preview-gallery-user","role":"user","content":[{"type":"text","text":"Show the seeded workspace, tool cards, subagents, and pagination in one tour."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":170,"time":1787472000170} +{"type":"step/start","data":{"turn":29,"step":1},"seq":171,"time":1787472000171} +{"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-gallery-tools","role":"assistant","content":[{"type":"reasoning","text":"I will inspect the deterministic workspace and collect each preview surface."},{"type":"tool-call","id":"preview-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},{"type":"tool-call","id":"preview-write","name":"write","arguments":"{\"file_path\":\"src/preview.ts\",\"content\":\"export const previewStatus = 'ready'\\n\\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\\n\"}"},{"type":"tool-call","id":"preview-bash","name":"bash","arguments":"{\"command\":\"printf 'preview ready\\\\n'\",\"description\":\"Print the preview readiness marker\"}"},{"type":"tool-call","id":"preview-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},{"type":"tool-call","id":"preview-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},{"type":"tool-call","id":"preview-web-search","name":"web_search","arguments":"{\"queries\":[\"Web Worker filesystem compatibility\"]}"},{"type":"tool-call","id":"preview-todo","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Inspect tool cards\",\"status\":\"completed\"},{\"content\":\"Open both subagents\",\"status\":\"completed\"},{\"content\":\"Load earlier history\",\"status\":\"in_progress\"}]}"},{"type":"tool-call","id":"preview-subagent","name":"subagent","arguments":"{\"description\":\"Continue preview verification\",\"prompt\":\"Check the remaining preview cases.\",\"run_in_background\":true}"},{"type":"tool-call","id":"preview-subagent-fork","name":"subagent_fork","arguments":"{\"description\":\"Review preview architecture\",\"prompt\":\"Review the fixture architecture.\",\"run_in_background\":false}"},{"type":"tool-call","id":"preview-failure","name":"read","arguments":"{\"file_path\":\"missing.txt\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":172,"time":1787472000172} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},"seq":173,"time":1787472000173} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-read-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-read","content":[{"type":"text","text":"PREVIEW.md\nfile\n\n1: # Preview Workspace\n2: \n3: This deterministic workspace is bundled with the browser-only preview.\n4: \n5: - `src/preview.ts` is the file changed by the example write result.\n6: - `data/tasks.json` mirrors the completed preview checklist.\n7: - `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing.\n8: \n9: Refresh the preview to restore these image bytes.\n\n(End of file - total 9 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"preview-read"}},"meta":{"path":"PREVIEW.md","offset":1,"lines":[{"number":1,"text":"# Preview Workspace"},{"number":2,"text":""},{"number":3,"text":"This deterministic workspace is bundled with the browser-only preview."},{"number":4,"text":""},{"number":5,"text":"- `src/preview.ts` is the file changed by the example write result."},{"number":6,"text":"- `data/tasks.json` mirrors the completed preview checklist."},{"number":7,"text":"- `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing."},{"number":8,"text":""},{"number":9,"text":"Refresh the preview to restore these image bytes."}],"totalLines":9,"lang":"md"}},"surfaceOp":"append","seq":174,"time":1787472000174} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-write","name":"write","arguments":"{\"file_path\":\"src/preview.ts\",\"content\":\"export const previewStatus = 'ready'\\n\\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\\n\"}"},"seq":175,"time":1787472000175} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-write-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-write","content":[{"type":"text","text":"src/preview.ts\nfile\n\nUpdated file\n"}],"isError":false}],"source":{"kind":"tool","callId":"preview-write"}},"meta":{"diffs":[{"path":"src/preview.ts","oldText":"export const previewStatus = 'draft'\n","newText":"export const previewStatus = 'ready'\n\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\n"}]}},"surfaceOp":"append","seq":176,"time":1787472000176} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-bash","name":"bash","arguments":"{\"command\":\"printf 'preview ready\\\\n'\",\"description\":\"Print the preview readiness marker\"}"},"seq":177,"time":1787472000177} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-bash-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-bash","content":[{"type":"text","text":"preview ready\n"}],"isError":false}],"source":{"kind":"tool","callId":"preview-bash"}}},"surfaceOp":"append","seq":178,"time":1787472000178} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},"seq":179,"time":1787472000179} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-glob-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-glob","content":[{"type":"text","text":"PREVIEW.md\ndata/tasks.json\nsrc/preview.ts"}],"isError":false}],"source":{"kind":"tool","callId":"preview-glob"}},"meta":{"shape":"paths","paths":["PREVIEW.md","data/tasks.json","src/preview.ts"],"truncated":false,"total":3}},"surfaceOp":"append","seq":180,"time":1787472000180} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},"seq":181,"time":1787472000181} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-grep-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-grep","content":[{"type":"text","text":"PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = 'ready'"}],"isError":false}],"source":{"kind":"tool","callId":"preview-grep"}},"meta":{"shape":"matches","files":[{"path":"PREVIEW.md","matches":[{"lineNumber":3,"line":"This deterministic workspace is bundled with the browser-only preview."}]},{"path":"src/preview.ts","matches":[{"lineNumber":1,"line":"export const previewStatus = 'ready'"}]}],"truncated":false,"total":2}},"surfaceOp":"append","seq":182,"time":1787472000182} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-web-search","name":"web_search","arguments":"{\"queries\":[\"Web Worker filesystem compatibility\"]}"},"seq":183,"time":1787472000183} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-web-search-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-web-search","content":[{"type":"text","text":"Browser workers can host deterministic in-memory filesystems.\n\nSources:\n1. MDN Web Workers API — https://developer.mozilla.org/docs/Web/API/Web_Workers_API"}],"isError":false}],"source":{"kind":"tool","callId":"preview-web-search"}},"meta":{"sources":[{"url":"https://developer.mozilla.org/docs/Web/API/Web_Workers_API","title":"Web Workers API","snippet":"Web Workers run scripts in background threads."}],"truncated":false,"answer":"Browser workers can host deterministic in-memory filesystems."}},"surfaceOp":"append","seq":184,"time":1787472000184} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-todo","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Inspect tool cards\",\"status\":\"completed\"},{\"content\":\"Open both subagents\",\"status\":\"completed\"},{\"content\":\"Load earlier history\",\"status\":\"in_progress\"}]}"},"seq":185,"time":1787472000185} +{"type":"todo/write","data":{"todos":[{"content":"Inspect tool cards","status":"completed"},{"content":"Open both subagents","status":"completed"},{"content":"Load earlier history","status":"in_progress"}]},"seq":186,"time":1787472000186} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-todo-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-todo","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 2 completed."}],"isError":false}],"source":{"kind":"tool","callId":"preview-todo"}}},"surfaceOp":"append","seq":187,"time":1787472000187} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-subagent","name":"subagent","arguments":"{\"description\":\"Continue preview verification\",\"prompt\":\"Check the remaining preview cases.\",\"run_in_background\":true}"},"seq":188,"time":1787472000188} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-subagent-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-subagent","content":[{"type":"text","text":"started subagent preview-follow-up-builder"}],"isError":false}],"source":{"kind":"tool","callId":"preview-subagent"}}},"surfaceOp":"append","seq":189,"time":1787472000189} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-subagent-fork","name":"subagent_fork","arguments":"{\"description\":\"Review preview architecture\",\"prompt\":\"Review the fixture architecture.\",\"run_in_background\":false}"},"seq":190,"time":1787472000190} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-subagent-fork-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-subagent-fork","content":[{"type":"text","text":"The preview fixture remains separate from user-owned WebFS data."}],"isError":false}],"source":{"kind":"tool","callId":"preview-subagent-fork"}}},"surfaceOp":"append","seq":191,"time":1787472000191} +{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-failure","name":"read","arguments":"{\"file_path\":\"missing.txt\"}"},"seq":192,"time":1787472000192} +{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-failure-result","role":"user","content":[{"type":"tool-result","toolCallId":"preview-failure","content":[{"type":"text","text":"Error: ENOENT: no such file, open missing.txt"}],"isError":true}],"source":{"kind":"tool","callId":"preview-failure"}},"error":{"name":"FsError","code":"ENOENT"}},"surfaceOp":"append","seq":193,"time":1787472000193} +{"type":"step/end","data":{"turn":29,"step":1},"seq":194,"time":1787472000194} +{"type":"step/start","data":{"turn":29,"step":2},"seq":195,"time":1787472000195} +{"type":"assistant/message","data":{"turn":29,"step":2,"message":{"id":"preview-gallery-final","role":"assistant","content":[{"type":"text","text":"## Preview tour complete\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":196,"time":1787472000196} +{"type":"step/end","data":{"turn":29,"step":2},"seq":197,"time":1787472000197} +{"type":"turn/end","data":{"turn":29,"reason":{"kind":"completed"}},"seq":198,"time":1787472000198} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json new file mode 100644 index 0000000000..9248dd7ae7 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/storages/session_projcache.json @@ -0,0 +1,24 @@ +{ + "unit": { + "name": "session_projcache", + "version": 3 + }, + "global": null, + "tables": { + "sessions": { + "preview-showcase": { + "identity": { + "createdAt": 1787472000000, + "cwd": "/dsh/workspace" + }, + "rows": { + "title": { + "ver": 1, + "seq": 198, + "val": "WebWorker Preview Showcase" + } + } + } + } + } +} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md new file mode 100644 index 0000000000..4d4f875565 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/.agents/skills/preview-tour/SKILL.md @@ -0,0 +1,8 @@ +--- +name: preview-tour +description: Inspect the bundled Preview workspace and its deterministic Session examples. +--- + +# Preview tour + +Read the workspace files, inspect the tool gallery, open both subagent histories, and load the earlier conversation page. diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md new file mode 100644 index 0000000000..ae96a96efe --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/PREVIEW.md @@ -0,0 +1,9 @@ +# Preview Workspace + +This deterministic workspace is bundled with the browser-only preview. + +- `src/preview.ts` is the file changed by the example write result. +- `data/tasks.json` mirrors the completed preview checklist. +- `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing. + +Refresh the preview to restore these image bytes. diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json new file mode 100644 index 0000000000..65ad487ce7 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/data/tasks.json @@ -0,0 +1,17 @@ +{ + "title": "Preview verification", + "tasks": [ + { + "name": "Inspect tool cards", + "status": "completed" + }, + { + "name": "Open both subagents", + "status": "completed" + }, + { + "name": "Load earlier history", + "status": "completed" + } + ] +} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts new file mode 100644 index 0000000000..1494e6ea20 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/workspace/src/preview.ts @@ -0,0 +1,3 @@ +export const previewStatus = 'ready' + +export const previewFeatures = ['tools', 'subagents', 'pagination'] as const diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index 6a5826a19a..e61693e479 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -108,10 +108,9 @@ it('reports that a synchronous run cannot happen, without throwing at the probe' }) expect(spawnSync(launcherPath(), ['--ro', '/', '--', 'echo', 'x']).error?.message) .toContain('commands run asynchronously') - expect(spawnSync(launcherPath(), ['--probe', '--'])).toMatchObject({ - status: LAUNCHER_FAILURE_EXIT, - stderr: expect.any(Buffer), - }) + const failedProbe = spawnSync(launcherPath(), ['--probe', '--']) + expect(failedProbe.status).toBe(LAUNCHER_FAILURE_EXIT) + expect(Buffer.isBuffer(failedProbe.stderr)).toBe(true) }) it('keeps the native Landlock package API and CLI failure contract', async () => { diff --git a/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts b/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts index 47aee5ec6b..efde651e9c 100644 --- a/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/tar.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' import { packTar, parseTar } from '../../src/storage/tar.ts' -import { loadVfsImage } from '../../src/storage/memory.ts' +import { loadVfsImage, loadVfsOverlay } from '../../src/storage/memory.ts' const encoder = new TextEncoder() @@ -38,4 +38,21 @@ describe('tar codec', () => { expect(vfs.existsSync('/dsh/workspace')).toBe(true) expect(vfs.existsSync('/dsh/absent')).toBe(false) }) + + it('applies ordered data overlays without exposing runtime paths', () => { + const vfs = loadVfsImage(packTar({ + 'config/cordis.yml': encoder.encode('- id: subject\n'), + 'workspace/status.txt': encoder.encode('base'), + }), '/dsh') + loadVfsOverlay(packTar({ + 'workspace/status.txt': encoder.encode('fixture'), + 'home/sessions/example/session.jsonl': encoder.encode('{}\n'), + }), '/dsh', vfs) + expect(vfs.readFileSync('/dsh/workspace/status.txt', 'utf8')).toBe('fixture') + expect(vfs.readFileSync('/dsh/home/sessions/example/session.jsonl', 'utf8')).toBe('{}\n') + expect(() => loadVfsOverlay(packTar({ + 'config/cordis.yml': encoder.encode('replaced'), + }), '/dsh', vfs)).toThrow(/overlay entry must stay under home\/ or workspace/) + expect(vfs.readFileSync('/dsh/config/cordis.yml', 'utf8')).toBe('- id: subject\n') + }) }) diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts new file mode 100644 index 0000000000..9519962309 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseInboundFrame } from '../../src/transport/frames.ts' + +describe('tunnel init frame', () => { + it('retains the selected overlay order', () => { + expect(parseInboundFrame({ + t: 'init', + image: 'base.tar.gz', + overlays: ['workspace.tar.gz', 'session.tar.gz'], + })).toEqual({ + t: 'init', + image: 'base.tar.gz', + overlays: ['workspace.tar.gz', 'session.tar.gz'], + }) + }) + + it('rejects a missing or non-string overlay list', () => { + expect(() => parseInboundFrame({ t: 'init', image: 'base.tar.gz' })).toThrow(/array of string overlay urls/) + expect(() => parseInboundFrame({ t: 'init', image: 'base.tar.gz', overlays: [1] })) + .toThrow(/array of string overlay urls/) + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index ceacba25df..64e550dc48 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -54,6 +54,27 @@ function stubWorker(): { } } +// The opening frame preserves overlay order for deterministic pre-boot mounts. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + tunnel.init('https://preview.test/base.tar.gz', [ + 'https://preview.test/first.tar.gz', + 'https://preview.test/second.tar.gz', + ]) + check('the init frame carries ordered overlays', sent[0], { + t: 'init', + image: 'https://preview.test/base.tar.gz', + overlays: ['https://preview.test/first.tar.gz', 'https://preview.test/second.tar.gz'], + }) + + const direct = stubWorker() + new WorkerTunnel(direct.worker).init('https://preview.test/base.tar.gz') + check('the direct init path defaults to no overlays', direct.sent[0], { + t: 'init', image: 'https://preview.test/base.tar.gz', overlays: [], + }) +} + // A normal reply resolves and says nothing on the console. { const { worker, sent, deliver } = stubWorker() diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts new file mode 100644 index 0000000000..561cf04699 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts @@ -0,0 +1,118 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { scanLog } from '@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts' +import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { + buildVfsExampleFiles, + VFS_EXAMPLE_OLDEST_MESSAGE, + VFS_EXAMPLE_ROOT, + VFS_EXAMPLE_SESSION_IDS, + VFS_EXAMPLE_TAIL_MESSAGE, + VFS_EXAMPLE_TITLE, +} from './vfs-example-fixture.ts' + +function filesUnder(root: string): string[] { + const files: string[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) files.push(relative(root, path).replaceAll('\\', '/')) + } + } + visit(root) + return files.sort() +} + +function readSession(id: string): ReturnType { + return scanLog(readFileSync( + join(VFS_EXAMPLE_ROOT, 'home/sessions/--dsh-workspace--', id, 'session.jsonl'), + )) +} + +function textOf(event: SessionEvent): string { + if (event.type === 'user/message') { + return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + } + if (event.type === 'assistant/message') { + return event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n') + } + return '' +} + +describe('WebWorker preview VFS example', () => { + it('matches its deterministic source byte for byte', () => { + const expected = buildVfsExampleFiles() + expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys()].sort()) + for (const [path, content] of expected) { + expect(readFileSync(join(VFS_EXAMPLE_ROOT, path), 'utf8'), path).toBe(content) + } + }) + + it('seeds the cold-list title cache against the main log identity', () => { + const cache = JSON.parse(readFileSync( + join(VFS_EXAMPLE_ROOT, 'home/storages/session_projcache.json'), + 'utf8', + )) as { + unit: { name: string; version: number } + tables: { sessions: Record } + } + expect(cache.unit).toEqual({ name: 'session_projcache', version: 3 }) + expect(cache.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]).toMatchObject({ + identity: { createdAt: 1_787_472_000_000, cwd: '/dsh/workspace' }, + rows: { title: { ver: 1, val: VFS_EXAMPLE_TITLE } }, + }) + }) + + it('restores the main production log with paging and tool coverage', () => { + const { meta, events } = readSession(VFS_EXAMPLE_SESSION_IDS.main) + expect(meta).toMatchObject({ + id: VFS_EXAMPLE_SESSION_IDS.main, + cwd: '/dsh/workspace', + delegationDepth: 0, + agentPreset: 'standard', + }) + expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index)) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(() => Session.fromRestore(SessionId(meta.id), events, meta)).not.toThrow() + + const messages = events.filter(event => + (event.type === 'user/message' || event.type === 'assistant/message') && event.surfaceOp === 'append') + expect(messages.length).toBeGreaterThan(50) + expect(messages.some(event => textOf(event).includes(VFS_EXAMPLE_OLDEST_MESSAGE))).toBe(true) + expect(messages.some(event => textOf(event).includes(VFS_EXAMPLE_TAIL_MESSAGE))).toBe(true) + expect(events.some(event => event.type === 'session/title' + && (event.data as { title?: unknown }).title === VFS_EXAMPLE_TITLE)).toBe(true) + + const tools = events.flatMap(event => event.type === 'tool/call' ? [event.data.name] : []) + expect(new Set(tools)).toEqual(new Set([ + 'read', 'write', 'bash', 'glob', 'grep', 'web_search', 'todo_write', 'subagent', 'subagent_fork', + ])) + expect(events.some(event => event.type === 'todo/write')).toBe(true) + expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError === true)).toBe(true) + }) + + it('restores one-shot and continuable child Sessions with durable descriptors', () => { + const expected = [ + [VFS_EXAMPLE_SESSION_IDS.oneShot, 'one-shot'], + [VFS_EXAMPLE_SESSION_IDS.continuable, 'continuable'], + ] as const + for (const [id, mode] of expected) { + const { meta, events } = readSession(id) + expect(meta).toMatchObject({ + id, + cwd: '/dsh/workspace', + parentSession: VFS_EXAMPLE_SESSION_IDS.main, + origin: 'subagent', + delegationDepth: 1, + agentPreset: 'standard', + }) + expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index)) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(foldSubagentDescriptor(events.slice(meta.seedLength ?? 0))).toMatchObject({ mode }) + expect(() => Session.fromRestore(SessionId(meta.id), events, meta)).not.toThrow() + } + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts new file mode 100644 index 0000000000..dbc151eb86 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts @@ -0,0 +1,432 @@ +/** Deterministic source for the filesystem tree bundled into the WebWorker preview. */ + +import { fileURLToPath } from 'node:url' +import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { + eventLines, projectKey, toHeaderLine, +} from '@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' + +/** Root copied by the preview image's repository adapter. */ +export const VFS_EXAMPLE_ROOT = fileURLToPath(new URL('./fixtures/vfs-example', import.meta.url)) + +/** Durable ids used by browser assertions and subagent parent links. */ +export const VFS_EXAMPLE_SESSION_IDS = { + main: SessionId('preview-showcase'), + oneShot: SessionId('preview-architecture-review'), + continuable: SessionId('preview-follow-up-builder'), +} as const + +/** Stable title rendered in the root Session list. */ +export const VFS_EXAMPLE_TITLE = 'WebWorker Preview Showcase' + +/** Oldest prompt, intentionally outside the first 50-message history page. */ +export const VFS_EXAMPLE_OLDEST_MESSAGE = 'History checkpoint 01: verify deterministic preview state.' + +/** Settled tail marker used by browser acceptance and the demonstration GIF. */ +export const VFS_EXAMPLE_TAIL_MESSAGE = 'Preview tour complete' + +const WORKSPACE = '/dsh/workspace' +const CREATED_AT = 1_787_472_000_000 +const HISTORICAL_TURNS = 28 + +const PREVIEW_GUIDE = `# Preview Workspace + +This deterministic workspace is bundled with the browser-only preview. + +- \`src/preview.ts\` is the file changed by the example write result. +- \`data/tasks.json\` mirrors the completed preview checklist. +- \`.agents/skills/preview-tour/SKILL.md\` proves dot directories survive image packing. + +Refresh the preview to restore these image bytes. +` + +const PREVIEW_SOURCE_BEFORE = 'export const previewStatus = \'draft\'\n' + +const PREVIEW_SOURCE = `export const previewStatus = 'ready' + +export const previewFeatures = ['tools', 'subagents', 'pagination'] as const +` + +const TASKS = `${JSON.stringify({ + title: 'Preview verification', + tasks: [ + { name: 'Inspect tool cards', status: 'completed' }, + { name: 'Open both subagents', status: 'completed' }, + { name: 'Load earlier history', status: 'completed' }, + ], +}, null, 2)}\n` + +const SKILL = `--- +name: preview-tour +description: Inspect the bundled Preview workspace and its deterministic Session examples. +--- + +# Preview tour + +Read the workspace files, inspect the tool gallery, open both subagent histories, and load the earlier conversation page. +` + +interface EventDraft { + readonly type: string + readonly data: unknown + readonly surfaceOp?: 'append' + readonly sourceEventSeqs?: number[] + readonly ignorable?: true +} + +class EventLog { + readonly events: SessionEvent[] + private nextTime: number + + constructor(time: number, seed: readonly SessionEvent[] = []) { + this.events = seed.map(event => structuredClone(event)) + this.nextTime = Math.max(time, (this.events.at(-1)?.time ?? time - 1) + 1) + } + + add(draft: EventDraft): number { + const seq = this.events.length + this.events.push({ ...draft, seq, time: this.nextTime++ } as unknown as SessionEvent) + return seq + } +} + +function userMessage(id: string, text: string): EventDraft { + return { + type: 'user/message', + data: { + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + } +} + +function assistantMessage(id: string, turn: number, step: number, content: unknown[]): EventDraft { + return { + type: 'assistant/message', + data: { + turn, + step, + message: { + id, + role: 'assistant', + content, + source: { kind: 'model', provider: 'preview-fixture', model: 'deterministic' }, + }, + }, + sourceEventSeqs: [], + surfaceOp: 'append', + } +} + +interface GalleryCall { + readonly id: string + readonly name: string + readonly args: Record + readonly result: string + readonly meta?: unknown + readonly error?: { readonly name: string; readonly code: string } + readonly todos?: Array<{ readonly content: string; readonly status: 'pending' | 'in_progress' | 'completed' }> +} + +function readResult(): { text: string; meta: unknown } { + const lines = PREVIEW_GUIDE.trimEnd().split('\n').map((text, index) => ({ number: index + 1, text })) + return { + text: `PREVIEW.md\nfile\n\n${lines.map(line => `${String(line.number)}: ${line.text}`).join('\n')}\n\n(End of file - total ${String(lines.length)} lines)\n`, + meta: { path: 'PREVIEW.md', offset: 1, lines, totalLines: lines.length, lang: 'md' }, + } +} + +function galleryCalls(): GalleryCall[] { + const read = readResult() + return [ + { + id: 'preview-read', + name: 'read', + args: { file_path: 'PREVIEW.md' }, + result: read.text, + meta: read.meta, + }, + { + id: 'preview-write', + name: 'write', + args: { file_path: 'src/preview.ts', content: PREVIEW_SOURCE }, + result: 'src/preview.ts\nfile\n\nUpdated file\n', + meta: { diffs: [{ path: 'src/preview.ts', oldText: PREVIEW_SOURCE_BEFORE, newText: PREVIEW_SOURCE }] }, + }, + { + id: 'preview-bash', + name: 'bash', + args: { command: "printf 'preview ready\\n'", description: 'Print the preview readiness marker' }, + result: 'preview ready\n', + }, + { + id: 'preview-glob', + name: 'glob', + args: { pattern: '**/*', path: '.' }, + result: 'PREVIEW.md\ndata/tasks.json\nsrc/preview.ts', + meta: { + shape: 'paths', + paths: ['PREVIEW.md', 'data/tasks.json', 'src/preview.ts'], + truncated: false, + total: 3, + }, + }, + { + id: 'preview-grep', + name: 'grep', + args: { pattern: 'preview', path: '.', include: '*.{md,ts,json}' }, + result: 'PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = \'ready\'', + meta: { + shape: 'matches', + files: [ + { path: 'PREVIEW.md', matches: [{ lineNumber: 3, line: 'This deterministic workspace is bundled with the browser-only preview.' }] }, + { path: 'src/preview.ts', matches: [{ lineNumber: 1, line: "export const previewStatus = 'ready'" }] }, + ], + truncated: false, + total: 2, + }, + }, + { + id: 'preview-web-search', + name: 'web_search', + args: { queries: ['Web Worker filesystem compatibility'] }, + result: 'Browser workers can host deterministic in-memory filesystems.\n\nSources:\n1. MDN Web Workers API — https://developer.mozilla.org/docs/Web/API/Web_Workers_API', + meta: { + sources: [{ + url: 'https://developer.mozilla.org/docs/Web/API/Web_Workers_API', + title: 'Web Workers API', + snippet: 'Web Workers run scripts in background threads.', + }], + truncated: false, + answer: 'Browser workers can host deterministic in-memory filesystems.', + }, + }, + { + id: 'preview-todo', + name: 'todo_write', + args: { + todos: [ + { content: 'Inspect tool cards', status: 'completed' }, + { content: 'Open both subagents', status: 'completed' }, + { content: 'Load earlier history', status: 'in_progress' }, + ], + }, + result: 'Updated todo list: 0 pending, 1 in progress, 2 completed.', + todos: [ + { content: 'Inspect tool cards', status: 'completed' }, + { content: 'Open both subagents', status: 'completed' }, + { content: 'Load earlier history', status: 'in_progress' }, + ], + }, + { + id: 'preview-subagent', + name: 'subagent', + args: { description: 'Continue preview verification', prompt: 'Check the remaining preview cases.', run_in_background: true }, + result: `started subagent ${VFS_EXAMPLE_SESSION_IDS.continuable}`, + }, + { + id: 'preview-subagent-fork', + name: 'subagent_fork', + args: { description: 'Review preview architecture', prompt: 'Review the fixture architecture.', run_in_background: false }, + result: 'The preview fixture remains separate from user-owned WebFS data.', + }, + { + id: 'preview-failure', + name: 'read', + args: { file_path: 'missing.txt' }, + result: 'Error: ENOENT: no such file, open missing.txt', + error: { name: 'FsError', code: 'ENOENT' }, + }, + ] +} + +function addClosedTextTurn(log: EventLog, turn: number): void { + const checkpoint = String(turn).padStart(2, '0') + log.add({ type: 'turn/start', data: { turn } }) + log.add(userMessage(`preview-user-${checkpoint}`, `History checkpoint ${checkpoint}: verify deterministic preview state.`)) + if (turn === 1) { + log.add({ + type: 'session/title', + data: { title: VFS_EXAMPLE_TITLE, messageSeqs: [], source: { kind: 'user' } }, + }) + } + log.add({ type: 'step/start', data: { turn, step: 1 } }) + log.add(assistantMessage( + `preview-assistant-${checkpoint}`, + turn, + 1, + [{ type: 'text', text: `Checkpoint ${checkpoint} is recorded.` }], + )) + log.add({ type: 'step/end', data: { turn, step: 1 } }) + log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) +} + +function mainLog(): { readonly events: SessionEvent[]; readonly forkSeedLength: number } { + const log = new EventLog(CREATED_AT) + for (let turn = 1; turn <= HISTORICAL_TURNS; turn++) addClosedTextTurn(log, turn) + const forkSeedLength = log.events.length + const turn = HISTORICAL_TURNS + 1 + const calls = galleryCalls() + + log.add({ type: 'turn/start', data: { turn } }) + log.add(userMessage('preview-gallery-user', 'Show the seeded workspace, tool cards, subagents, and pagination in one tour.')) + log.add({ type: 'step/start', data: { turn, step: 1 } }) + log.add(assistantMessage('preview-gallery-tools', turn, 1, [ + { type: 'reasoning', text: 'I will inspect the deterministic workspace and collect each preview surface.' }, + ...calls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: JSON.stringify(call.args) })), + ])) + for (const call of calls) { + log.add({ + type: 'tool/call', + data: { turn, step: 1, callId: call.id, name: call.name, arguments: JSON.stringify(call.args) }, + }) + if (call.todos !== undefined) log.add({ type: 'todo/write', data: { todos: call.todos } }) + log.add({ + type: 'tool/result', + data: { + turn, + step: 1, + message: { + id: `${call.id}-result`, + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: call.id, + content: [{ type: 'text', text: call.result }], + isError: call.error !== undefined, + }], + source: { kind: 'tool', callId: call.id }, + }, + ...call.meta === undefined ? {} : { meta: call.meta }, + ...call.error === undefined ? {} : { error: call.error }, + }, + surfaceOp: 'append', + }) + } + log.add({ type: 'step/end', data: { turn, step: 1 } }) + log.add({ type: 'step/start', data: { turn, step: 2 } }) + log.add(assistantMessage('preview-gallery-final', turn, 2, [{ + type: 'text', + text: `## ${VFS_EXAMPLE_TAIL_MESSAGE}\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.`, + }])) + log.add({ type: 'step/end', data: { turn, step: 2 } }) + log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + return { events: log.events, forkSeedLength } +} + +function oneShotLog(seed: readonly SessionEvent[]): SessionEvent[] { + const log = new EventLog(CREATED_AT + 100_000, seed) + log.add({ type: 'session/end-seed', data: {} }) + const turn = HISTORICAL_TURNS + 1 + log.add({ type: 'turn/start', data: { turn } }) + log.add(userMessage('preview-review-user', 'Review whether the preview fixture is isolated from future WebFS data.')) + log.add({ + type: 'subagent/descriptor', + data: snapshotSubagentDescriptor({ + mode: 'one-shot', provider: 'fork', label: 'Review preview architecture', + }), + }) + log.add({ type: 'step/start', data: { turn, step: 1 } }) + log.add(assistantMessage('preview-review-assistant', turn, 1, [{ + type: 'text', + text: 'The bundled fixture is static image content; future WebFS state remains user-owned.', + }])) + log.add({ type: 'step/end', data: { turn, step: 1 } }) + log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) + return log.events +} + +function continuableLog(): SessionEvent[] { + const log = new EventLog(CREATED_AT + 200_000) + log.add({ type: 'turn/start', data: { turn: 1 } }) + log.add(userMessage('preview-builder-user', 'Check that the Preview workspace can support follow-up tasks.')) + log.add({ + type: 'subagent/descriptor', + data: snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'spawn', label: 'Continue preview verification', + }), + }) + log.add({ type: 'step/start', data: { turn: 1, step: 1 } }) + log.add(assistantMessage('preview-builder-assistant', 1, 1, [{ + type: 'text', + text: 'This child is continuable and ready for another verification turn.', + }])) + log.add({ type: 'step/end', data: { turn: 1, step: 1 } }) + log.add({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }) + return log.events +} + +function header( + id: SessionHeader['id'], + createdAt: number, + child?: { readonly parentSession: SessionHeader['id']; readonly mode: 'one-shot' | 'continuable'; readonly seedLength?: number }, +): SessionHeader { + return { + version: 0, + id, + createdAt, + cwd: WORKSPACE, + delegationDepth: child === undefined ? 0 : 1, + agentPreset: 'standard', + ...child === undefined ? {} : { + parentSession: child.parentSession, + origin: 'subagent' as const, + ...child.seedLength === undefined ? {} : { seedLength: child.seedLength }, + }, + } +} + +function renderLog(meta: SessionHeader, events: readonly SessionEvent[]): string { + return `${JSON.stringify(toHeaderLine(meta))}\n${eventLines(events, true)}\n` +} + +/** Build every committed fixture file as repository-relative UTF-8 text. */ +export function buildVfsExampleFiles(): ReadonlyMap { + const main = mainLog() + const project = projectKey(WORKSPACE) + const sessionPath = (id: string): string => `home/sessions/${project}/${id}/session.jsonl` + const projectionCache = `${JSON.stringify({ + unit: { name: 'session_projcache', version: 3 }, + global: null, + tables: { + sessions: { + [VFS_EXAMPLE_SESSION_IDS.main]: { + identity: { createdAt: CREATED_AT, cwd: WORKSPACE }, + rows: { + title: { ver: 1, seq: main.events.at(-1)?.seq ?? -1, val: VFS_EXAMPLE_TITLE }, + }, + }, + }, + }, + }, null, 2)}\n` + return new Map([ + ['workspace/PREVIEW.md', PREVIEW_GUIDE], + ['workspace/src/preview.ts', PREVIEW_SOURCE], + ['workspace/data/tasks.json', TASKS], + ['workspace/.agents/skills/preview-tour/SKILL.md', SKILL], + ['home/storages/session_projcache.json', projectionCache], + [sessionPath(VFS_EXAMPLE_SESSION_IDS.main), renderLog( + header(VFS_EXAMPLE_SESSION_IDS.main, CREATED_AT), + main.events, + )], + [sessionPath(VFS_EXAMPLE_SESSION_IDS.oneShot), renderLog( + header(VFS_EXAMPLE_SESSION_IDS.oneShot, CREATED_AT + 100_000, { + parentSession: VFS_EXAMPLE_SESSION_IDS.main, + mode: 'one-shot', + seedLength: main.forkSeedLength, + }), + oneShotLog(main.events.slice(0, main.forkSeedLength)), + )], + [sessionPath(VFS_EXAMPLE_SESSION_IDS.continuable), renderLog( + header(VFS_EXAMPLE_SESSION_IDS.continuable, CREATED_AT + 200_000, { + parentSession: VFS_EXAMPLE_SESSION_IDS.main, + mode: 'continuable', + }), + continuableLog(), + )], + ]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c25bbf2ce..e763188fd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4848,6 +4848,15 @@ 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-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local diff --git a/scripts/session-fixture-layout.spec.ts b/scripts/session-fixture-layout.spec.ts index c0b87953b7..05dfd7a4d0 100644 --- a/scripts/session-fixture-layout.spec.ts +++ b/scripts/session-fixture-layout.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { canonicalSessionFixture } from './session-fixture-layout.ts' +import { canonicalSessionFixture, isPhysicalSessionFixture } from './session-fixture-layout.ts' const HEADER = ' {"type":"session","version":0,"id":"fixture","createdAt":1,"delegationDepth":0} ' @@ -68,3 +68,15 @@ describe('canonicalSessionFixture', () => { .toThrow(/broken\.jsonl: session snapshot line 2: malformed text-chunks storage row/) }) }) + +describe('isPhysicalSessionFixture', () => { + it('excludes only persisted logs under the WebWorker example root', () => { + expect(isPhysicalSessionFixture( + 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/main/session.jsonl', + )).toBe(true) + expect(isPhysicalSessionFixture( + 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/README.jsonl', + )).toBe(false) + expect(isPhysicalSessionFixture('apps/web/tests/snapshots/example/session.jsonl')).toBe(false) + }) +}) diff --git a/scripts/session-fixture-layout.ts b/scripts/session-fixture-layout.ts index 996b40fbca..e26cb7f90b 100644 --- a/scripts/session-fixture-layout.ts +++ b/scripts/session-fixture-layout.ts @@ -7,6 +7,10 @@ import { resolve } from 'node:path' import { packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +/** Physical persistence artifacts validated by the WebWorker runtime fixture spec. */ +const PHYSICAL_SESSION_FIXTURE_ROOT = + 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/' + /** One repository session fixture and its canonical projected representation. */ export interface SessionFixtureLayout { /** Repository-relative path with `/` separators. */ @@ -17,6 +21,16 @@ export interface SessionFixtureLayout { canonical: string } +/** + * Whether a repository JSONL is a production-layout persistence artifact rather + * than an envelope-free replay snapshot owned by this script. + * @param path - Repository-relative path with `/` separators. + * @returns True only for Session logs under the WebWorker VFS example root. + */ +export function isPhysicalSessionFixture(path: string): boolean { + return path.startsWith(PHYSICAL_SESSION_FIXTURE_ROOT) && path.endsWith('/session.jsonl') +} + function isSessionHeader(value: unknown): boolean { return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === 'session' } @@ -109,6 +123,7 @@ function discoverJsonlFiles(root: string): string[] { */ export function inspectSessionFixtureLayouts(root: string): SessionFixtureLayout[] { return discoverJsonlFiles(root).flatMap((path) => { + if (isPhysicalSessionFixture(path)) return [] const source = readFileSync(resolve(root, path), 'utf8') const canonical = canonicalSessionFixture(source, path) return canonical === undefined ? [] : [{ path, source, canonical }] From 14bd300880e83960758e67023ac9be0bcf8226e4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:26:03 +0800 Subject: [PATCH 12/21] fix(webworker): match preview chooser styling --- .../src/client/source-chooser.ts | 174 +++++++++++++----- 1 file changed, 130 insertions(+), 44 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/client/source-chooser.ts b/packages/experimental/webworker-runtime/src/client/source-chooser.ts index 6a5174ccd3..50f4e3769f 100644 --- a/packages/experimental/webworker-runtime/src/client/source-chooser.ts +++ b/packages/experimental/webworker-runtime/src/client/source-chooser.ts @@ -17,64 +17,148 @@ interface PreviewSourceChoice { } const CHOOSER_STYLE = ` - :root { color-scheme: light dark; } - body { margin: 0; } [data-preview-source-chooser] { - min-height: 100vh; + position: fixed; + inset: 0; + z-index: 1200; display: grid; place-items: center; + overflow: auto; padding: 24px; box-sizing: border-box; - color: #171717; - background: radial-gradient(circle at 50% 35%, #eef4ff 0, #f8fafc 42%, #f3f4f6 100%); - font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #0f1115; + background: #fff; + font-size: 14px; + line-height: 22px; } + #root:has(> [data-preview-source-chooser]) > [data-dsh-boot] { display: none; } [data-preview-source-card] { - width: min(560px, 100%); + width: min(600px, 100%); + max-height: calc(100dvh - 48px); box-sizing: border-box; padding: 28px; - border: 1px solid #d8dee9; - border-radius: 20px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12); + overflow-y: auto; + border: 1px solid transparent; + border-radius: 24px; + background: #fff; + box-shadow: 0 0 1px rgb(0 0 0 / 20%), 0 12px 32px rgb(0 0 0 / 8%); } - [data-preview-source-card] h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.25; } - [data-preview-source-card] > p { margin: 0 0 22px; color: #5b6472; } - [data-preview-source-card] fieldset { display: grid; gap: 10px; margin: 0; padding: 0; border: 0; } - [data-preview-source-card] legend { margin-bottom: 10px; font-weight: 650; } - [data-preview-source-option] { - display: grid; - grid-template-columns: auto 1fr; - gap: 2px 12px; - padding: 14px; - border: 1px solid #d8dee9; - border-radius: 12px; - cursor: pointer; + [data-preview-source-card] h1 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 500; } - [data-preview-source-option]:has(input:checked) { border-color: #4777df; background: #edf3ff; } - [data-preview-source-option]:has(input:disabled) { cursor: not-allowed; opacity: 0.55; } - [data-preview-source-option] input { grid-row: 1 / span 2; margin: 4px 0 0; } - [data-preview-source-option] strong { font-size: 15px; } - [data-preview-source-option] span { color: #667085; } - [data-preview-source-submit] { - width: 100%; - margin-top: 20px; - padding: 11px 16px; + [data-preview-source-card] > p { + margin: 8px 0 0; + color: #61666b; + } + [data-preview-source-card] fieldset { + display: flex; + flex-direction: column; + gap: 1px; + margin: 24px 0 0; + padding: 0; border: 0; - border-radius: 10px; - color: white; - background: #315fc7; - font: inherit; - font-weight: 650; + } + [data-preview-source-card] legend { + margin: 0 0 8px; + padding: 0 4px; + color: #61666b; + font-size: 13px; + line-height: 20px; + font-weight: 500; + } + [data-preview-source-option] { + position: relative; + display: flex; + align-items: flex-start; + gap: 8px; + min-height: 56px; + padding: 8px 12px 8px 8px; + box-sizing: border-box; + border: 1px solid transparent; + border-radius: 12px; + background: transparent; cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; + } + [data-preview-source-option]:hover:not(:has(input:disabled)), + [data-preview-source-option]:has(input:checked) { + background: rgb(38 49 72 / 6%); + } + [data-preview-source-option]:has(input:checked) { + border-color: rgb(0 0 0 / 10%); + } + [data-preview-source-option]:has(input:disabled) { + cursor: default; + opacity: 0.4; + } + [data-preview-source-option] input { + flex: none; + width: 16px; + height: 16px; + margin: 4px 0 0; + accent-color: #0f1115; + } + [data-preview-source-option] > span { flex: 1; min-width: 0; } + [data-preview-source-option] strong { + display: block; + font-size: 14px; + line-height: 24px; + font-weight: 500; + } + [data-preview-source-option] strong + span { + display: block; + color: #81858c; + font-size: 14px; + line-height: 24px; + } + [data-preview-source-submit] { + display: block; + min-width: 120px; + height: 36px; + margin: 24px 0 0 auto; + padding: 0 14px; + border: 0; + border-radius: 18px; + color: #fff; + background: #0f1115; + font-size: 14px; + line-height: 22px; + cursor: pointer; + transition: background-color 120ms ease; + } + [data-preview-source-submit]:hover:not(:disabled) { + background: #43454a; + } + [data-preview-source-submit]:focus-visible { + outline: 2px solid rgb(0 0 0 / 16%); + outline-offset: 2px; } [data-preview-source-submit]:disabled { cursor: not-allowed; opacity: 0.5; } @media (prefers-color-scheme: dark) { - [data-preview-source-chooser] { color: #f4f4f5; background: radial-gradient(circle at 50% 35%, #172554 0, #111827 45%, #09090b 100%); } - [data-preview-source-card] { border-color: #374151; background: rgba(24, 24, 27, 0.96); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); } - [data-preview-source-card] > p, [data-preview-source-option] span { color: #a1a1aa; } - [data-preview-source-option] { border-color: #3f3f46; } - [data-preview-source-option]:has(input:checked) { border-color: #7aa2ff; background: #172554; } + [data-preview-source-chooser] { + color: #f9fafb; + background: #151517; + } + [data-preview-source-card] { border-color: rgb(255 255 255 / 6%); background: #2c2c2e; } + [data-preview-source-card] > p, [data-preview-source-card] legend { color: #cfd3d6; } + [data-preview-source-option] strong + span { color: #adb2b8; } + [data-preview-source-option]:hover:not(:has(input:disabled)), + [data-preview-source-option]:has(input:checked) { background: rgb(255 255 255 / 8%); } + [data-preview-source-option]:has(input:checked) { border-color: rgb(255 255 255 / 12%); } + [data-preview-source-option] input { accent-color: #f9fafb; } + [data-preview-source-submit] { color: #0f1115; background: #f9fafb; } + [data-preview-source-submit]:hover:not(:disabled) { background: #ebeef2; } + [data-preview-source-submit]:focus-visible { outline-color: rgb(255 255 255 / 20%); } + } + @media (max-width: 560px) { + [data-preview-source-card] { padding: 24px; } + [data-preview-source-submit] { width: 100%; } + } + @media (prefers-reduced-motion: reduce) { + [data-preview-source-option], [data-preview-source-submit] { transition: none; } } ` @@ -89,8 +173,10 @@ function escapeMarkup(value: string): string { function optionMarkup(choice: PreviewSourceChoice, selected: string): string { return `` } From 4f80422595cdcf787a089c712fd312414b89c1a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:56:41 +0800 Subject: [PATCH 13/21] fix(webworker): preserve preview loading sequence --- apps/web/tests/preview-boot.e2e.ts | 9 +++--- .../preview-boot/source-chooser.expected.md | 30 +++++++++---------- .../webworker-packer/src/repository.ts | 4 +-- .../src/client/source-chooser.ts | 30 +++++++++---------- .../tests/client/source-chooser.spec.ts | 12 ++++++-- 5 files changed, 47 insertions(+), 38 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index b4675926bd..8ef63e1067 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -283,10 +283,10 @@ async function bootPreview(origin: string, browser: Browser): Promise { }) try { await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' }) - await page.getByRole('heading', { name: '选择 Preview 数据源' }).waitFor() + await page.getByRole('heading', { name: 'Choose Preview data' }).waitFor() expect(await page.locator('input[name="preview-source"][value="vfs-example"]').isChecked()).toBe(true) - expect(await page.getByText('空白环境', { exact: true }).count()).toBe(1) - expect(await page.getByText('WebFS 目录', { exact: true }).count()).toBe(1) + expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1) + expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1) expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true) expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0) await compareOrRefreshGolden( @@ -294,7 +294,8 @@ async function bootPreview(origin: string, browser: Browser): Promise { await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'), SNAPSHOT_MODE, ) - await page.getByRole('button', { name: '启动 Preview' }).click() + await page.getByRole('button', { name: 'Start Preview' }).click() + await page.getByText('Loading plugins…', { exact: true }).waitFor({ timeout: 10_000 }) const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`) // The activated tree ran bodies lowered against the contract this // checkout's packer emits; a dist built before a contract change would diff --git a/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md index 18dd2a442b..1ba068c6b5 100644 --- a/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md +++ b/apps/web/tests/snapshots/preview-boot/source-chooser.expected.md @@ -1,15 +1,15 @@ -- form "选择 Preview 数据源": - - heading "选择 Preview 数据源" [level=1] - - paragraph: 数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。 - - group "文件系统来源": - - text: 文件系统来源 - - radio "空白环境 只加载基础运行时,用于验证首次启动与新建 Workspace。" - - strong: 空白环境 - - text: 只加载基础运行时,用于验证首次启动与新建 Workspace。 - - radio "内置综合示例 示例 Workspace、工具卡、子代理与分页会话。" [checked] - - strong: 内置综合示例 - - text: 示例 Workspace、工具卡、子代理与分页会话。 - - radio "WebFS 目录 需要用户授权的目录来源,将在 WebFS provider 接入后开放。" [disabled] - - strong: WebFS 目录 - - text: 需要用户授权的目录来源,将在 WebFS provider 接入后开放。 - - button "启动 Preview" +- form "Choose Preview data": + - heading "Choose Preview data" [level=1] + - paragraph: Data mounts before the Worker and application start. Refresh to choose again. + - group "Filesystem source": + - text: Filesystem source + - radio "Empty environment Load only the base runtime to verify first launch and workspace creation." + - strong: Empty environment + - text: Load only the base runtime to verify first launch and workspace creation. + - radio "Built-in showcase Sample workspace, tool cards, subagents, and paged history." [checked] + - strong: Built-in showcase + - text: Sample workspace, tool cards, subagents, and paged history. + - radio "WebFS directory Requires directory access and will be available after the WebFS provider lands." [disabled] + - strong: WebFS directory + - text: Requires directory access and will be available after the WebFS provider lands. + - button "Start Preview" diff --git a/packages/experimental/webworker-packer/src/repository.ts b/packages/experimental/webworker-packer/src/repository.ts index 5e433ea425..cdfda6e547 100644 --- a/packages/experimental/webworker-packer/src/repository.ts +++ b/packages/experimental/webworker-packer/src/repository.ts @@ -156,8 +156,8 @@ export function previewFixtures(repoRoot: string): PreviewFixture[] { const root = join(repoRoot, PREVIEW_EXAMPLE_ROOT) return [{ id: 'vfs-example', - label: '内置综合示例', - description: '示例 Workspace、工具卡、子代理与分页会话。', + label: 'Built-in showcase', + description: 'Sample workspace, tool cards, subagents, and paged history.', trees: ['home', 'workspace'].map(mount => ({ mount, directory: join(root, mount) })), }] } diff --git a/packages/experimental/webworker-runtime/src/client/source-chooser.ts b/packages/experimental/webworker-runtime/src/client/source-chooser.ts index 50f4e3769f..48d454eb94 100644 --- a/packages/experimental/webworker-runtime/src/client/source-chooser.ts +++ b/packages/experimental/webworker-runtime/src/client/source-chooser.ts @@ -31,7 +31,6 @@ const CHOOSER_STYLE = ` font-size: 14px; line-height: 22px; } - #root:has(> [data-preview-source-chooser]) > [data-dsh-boot] { display: none; } [data-preview-source-card] { width: min(600px, 100%); max-height: calc(100dvh - 48px); @@ -206,15 +205,15 @@ export async function choosePreviewSource(manifestUrl: URL): Promise -
-

选择 Preview 数据源

-

数据会在 Worker 和应用启动前挂载;刷新页面可重新选择。

+ const chooser = document.createElement('main') + chooser.dataset.previewSourceChooser = '' + chooser.innerHTML = ` +

Choose Preview data

+

Data mounts before the Worker and application start. Refresh to choose again.

- 文件系统来源 + Filesystem source ${choices.map(choice => optionMarkup(choice, selected)).join('')}
- -
- ` - const form = root.querySelector('[data-preview-source-card]') + + ` + root.prepend(chooser) + const form = chooser.querySelector('[data-preview-source-card]') if (form === null) throw new Error('preview source chooser: form was not rendered') const sourceId = await new Promise((resolve, reject) => { form.addEventListener('submit', (event) => { @@ -258,7 +258,7 @@ export async function choosePreviewSource(manifestUrl: URL): Promise candidate.id === sourceId && candidate.disabled !== true) if (choice === undefined) throw new Error(`preview source chooser: unavailable source "${sourceId}"`) - root.replaceChildren() + chooser.remove() style.remove() return choice.overlays } diff --git a/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts index 6c629a2af8..f636c32ebe 100644 --- a/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts +++ b/packages/experimental/webworker-runtime/tests/client/source-chooser.spec.ts @@ -80,6 +80,11 @@ describe('Preview source chooser', () => { it('shows the chooser only when the query is absent and returns its default selection', async () => { installManifest() + const root = document.getElementById('root') + if (root === null) throw new Error('test root is missing') + const bootPage = document.createElement('div') + bootPage.dataset.dshBoot = '' + root.append(bootPage) const selected = choosePreviewSource(MANIFEST_URL) await vi.waitFor(() => { @@ -97,7 +102,9 @@ describe('Preview source chooser', () => { new URL('https://preview.test/preview/fixtures/base.tar.gz'), new URL('https://preview.test/preview/fixtures/tail.tar.gz'), ]) - expect(document.getElementById('root')?.childElementCount).toBe(0) + expect(root.contains(bootPage)).toBe(true) + expect(root.childElementCount).toBe(1) + expect(document.querySelector('[data-preview-source-chooser]')).toBeNull() expect(document.querySelector('[data-preview-source-style]')).toBeNull() }) @@ -124,8 +131,9 @@ describe('Preview source chooser', () => { document.body.innerHTML = '
' const root = document.getElementById('root') if (root === null) throw new Error('test root is missing') - vi.spyOn(root, 'querySelector').mockReturnValueOnce(null) + const querySelector = vi.spyOn(HTMLElement.prototype, 'querySelector').mockReturnValueOnce(null) await expect(choosePreviewSource(MANIFEST_URL)).rejects.toThrow(/form was not rendered/) + querySelector.mockRestore() document.head.replaceChildren() document.body.innerHTML = '
' From 5ad9b128f9b6ec0e120b299591f2bcfbd3f43159 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:39:48 +0800 Subject: [PATCH 14/21] fix(webworker): align filesystem semantics with Node --- ...webworker-vfs-watch-and-landlock.i18n.yaml | 4 +- ...-08-23-webworker-vfs-watch-and-landlock.md | 6 +- ...-23-webworker-vfs-watch-and-landlock.zh.md | 6 +- .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../webworker-runtime/src/fixture-manifest.ts | 1 + .../implemented/abort-error.ts | 14 + .../builtin_modules/implemented/fs-watch.ts | 18 +- .../node/builtin_modules/implemented/fs.ts | 112 ++++---- .../src/shell/process/landlock.ts | 3 +- .../webworker-runtime/src/storage/memory.ts | 263 ++++++++++++++---- .../webworker-runtime/src/storage/types.ts | 46 ++- .../tests/node/child-process.spec.ts | 8 + .../tests/node/fs-watch-stream.spec.ts | 229 ++++++++++++++- .../tests/storage/memory-vfs.spec.ts | 56 +++- 16 files changed, 614 insertions(+), 160 deletions(-) create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml index 34a24f6261..4260ae5238 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md -2026-08-23-webworker-vfs-watch-and-landlock.md: 61254092e0f32e8e4291fd7c21489684110a1d15 -2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 32e1b36e0ef17e4574252693e246f9d7cd4d4712 +2026-08-23-webworker-vfs-watch-and-landlock.md: 2705c63aa6bf0f2e2de33d00029a4f41e1b1d4af +2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 4470720e1ff968aa06578b231b054c9053f27283 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md index 61254092e0..2705c63aa6 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md @@ -20,13 +20,13 @@ The filesystem compatibility boundary follows the [Worker Node face decision](20 The mutation record is shared with WebFS persistence rather than defining a second notification path. Writes carry their complete post-commit bytes and virtual permission bits, plus an append offset when only a tail changed. `MemoryVfs` accepts an optional asynchronous `VfsMutationSink`, sends the same records to that sink and live watcher subscribers, and exposes `flush()` through file-handle `sync()` and `datasync()`. Hydration supplies `{ mode, mtimeMs }` explicitly, so image permissions and durable timestamps cannot occupy the same positional argument. This change mounts no durable sink; it keeps the synchronous in-memory tree authoritative so an OPFS or user-directory mirror can hydrate before publication and write behind without changing `node:fs`. -The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. +The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. A pre-aborted callback watch returns its watcher before asynchronously closing it, while a pre-aborted promise watch rejects its first iterator read with `AbortError`. `fs.watch` maps entry creation, removal, and rename destinations to `rename`, and maps content or mode changes to `change`. Non-recursive directory watches report immediate child names; recursive watches report paths relative to the watched directory. The VFS has no symlinks, so this implementation does not invent symlink events. ### Streams and unchanged npm packages -`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. +`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. Descriptors retain their opened file identity and access mode across rename, replacement, and unlink; hard links share that identity and subsequent content or mode changes, while truncation zero-fills growth. Chokidar and readdirp are ordinary image dependencies, not module replacements. Their package code runs unchanged and imports the Worker implementations of `node:fs`, `node:fs/promises`, `node:stream`, `node:events`, `node:path`, and `node:os`. Chokidar therefore retains its own initial scan, `ready`, polling, atomic-write normalization, write-settle delay, shared watcher, and close behavior. @@ -36,7 +36,7 @@ Chokidar and readdirp are ordinary image dependencies, not module replacements. The process layer has a table of Worker platform executables identified by logical executable name rather than one package-manager path. Its `landlock-run` provider accepts a bare command or an absolute launcher path, parses the native package's unchanged CLI, validates every grant root, and delegates the inner argv to the existing shell process runner. `node:child_process` performs only generic executable lookup, output delivery, and settlement. The unchanged package's synchronous `probe()` therefore observes the provider through `spawnSync` and reports `full`. A usage error, missing grant root, or unknown inner executable prints one `landlock-run: ...` line, exits `125`, and never runs the inner command. The bwrap probe remains unavailable, so the unmodified `sandbox-local` Linux chain selects this Landlock backend. -Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. +Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Grant roots normalize trailing separators before containment checks. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. The Worker's `full` verdict covers every file operation expressible through its shell command table and Host-served VFS protocol. It does not claim Linux kernel Landlock, arbitrary native executable support, or protection against a future shell program that bypasses `ShellFileSystem`. diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md index 32e1b36e0e..4470720e1f 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md @@ -20,13 +20,13 @@ Web Worker preview 启动与 Node host 相同的 Web profile 和 Agent preset。 Mutation record 与 WebFS 持久化共用,而不建立第二条通知路径。Write 记录携带提交后的完整字节与虚拟权限位,并在只有尾部变化时携带 append offset。`MemoryVfs` 接受可选的异步 `VfsMutationSink`,把同一批记录交给 sink 与实时 watcher 订阅方,并通过文件句柄的 `sync()` 和 `datasync()` 暴露 `flush()`。水合通过显式的 `{ mode, mtimeMs }` 传入元数据,因此镜像权限与持久化时间戳不会占用同一个位置参数。本次变更不挂载 durable sink;同步内存树继续作为权威,因此 OPFS 或用户目录 mirror 可以先水合、再异步写回,而无需改变 `node:fs`。 -`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。 +`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。预先 abort 的 callback watcher 先返回对象、再异步关闭;预先 abort 的 promise watcher 在第一次读取 iterator 时以 `AbortError` 拒绝。 `fs.watch` 把条目创建、删除和 rename 目标映射为 `rename`,把内容或 mode 变化映射为 `change`。非递归目录 watcher 报告直接子项名,递归 watcher 报告相对被监听目录的路径。VFS 没有符号链接,因此该实现不会制造符号链接事件。 ### Stream 与未修改的 NPM 包 -`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。 +`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。Descriptor 在 rename、replacement 和 unlink 后仍保留打开时的文件身份与访问模式;hard link 共享该身份及后续内容和 mode 变化,truncate 增长则用零字节填充。 Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replacement。它们的包代码保持原样,并导入 Worker 实现的 `node:fs`、`node:fs/promises`、`node:stream`、`node:events`、`node:path` 与 `node:os`。因此,初次扫描、`ready`、polling、原子写归一化、写入稳定等待、共享 watcher 与关闭行为仍由 Chokidar 自己负责。 @@ -36,7 +36,7 @@ Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replaceme 进程层持有按逻辑可执行文件名识别的 Worker 平台可执行文件表,而不依赖某一个包管理器路径。其 `landlock-run` provider 接受裸命令或绝对 launcher 路径,解析 native 包未经修改的 CLI、校验每个授权根,并把内部 argv 交给既有 shell 进程 runner。`node:child_process` 只负责通用的可执行文件查找、输出投递与结束处理。因此,原包的同步 `probe()` 会通过 `spawnSync` 观察到该 provider 并报告 `full`。用法错误、缺失的授权根或未知内部可执行文件只输出一行 `landlock-run: ...`,以 `125` 退出,并且绝不运行内部命令。bwrap 仍探测为不可用,因此未修改的 `sandbox-local` Linux 选择链会选中该 Landlock 后端。 -每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 +每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。Grant root 在 containment 检查前去除尾部分隔符。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 Worker 的 `full` 结论覆盖 shell 命令表和 Host 服务 VFS 协议能够表达的全部文件操作。它不表示 Linux 内核 Landlock、不支持任意 native 可执行文件,也无法约束未来绕过 `ShellFileSystem` 的 shell 程序。 diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index f5d10e0a50..9b012cd360 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 5b63856b826d4f8bc8b6ff56626c6e7a1ed663b1 -README.zh.md: 59b31bd308077b8eeaa60edf5bd23a75924eaa07 +README.md: df6dacad35273f8c636a86c7c260b4f5d1958a6a +README.zh.md: 721770149c04169d813d2b5d3d4faafdccf1dec5 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 5b63856b82..df6dacad35 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -7,7 +7,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 59b31bd308..721770149c 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -7,7 +7,7 @@ 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 diff --git a/packages/experimental/webworker-runtime/src/fixture-manifest.ts b/packages/experimental/webworker-runtime/src/fixture-manifest.ts index d594960576..7d2ecf2556 100644 --- a/packages/experimental/webworker-runtime/src/fixture-manifest.ts +++ b/packages/experimental/webworker-runtime/src/fixture-manifest.ts @@ -17,6 +17,7 @@ export interface PreviewFixtureManifestEntry { /** Complete built-in fixture catalog consumed before Worker startup. */ export interface PreviewFixtureManifest { readonly version: number + /** Required default fixture id, or null when the chooser should default to an empty overlay. */ readonly defaultFixture: string | null readonly fixtures: readonly PreviewFixtureManifestEntry[] } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts new file mode 100644 index 0000000000..a7f188bdc1 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/abort-error.ts @@ -0,0 +1,14 @@ +/** Build the Node-style cancellation error shared by abortable builtin APIs. */ + +/** + * Create an `AbortError` carrying Node's stable error code. + * @param reason - Optional AbortSignal reason exposed as the error cause. + * @returns A Node-compatible abort error. + */ +export function abortError(reason?: unknown): Error & { code: string; cause?: unknown } { + const error = new Error('The operation was aborted') as Error & { code: string; cause?: unknown } + error.name = 'AbortError' + error.code = 'ABORT_ERR' + if (reason !== undefined) error.cause = reason + return error +} diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts index 71864a7caf..61e3f4419c 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -5,6 +5,7 @@ import { captureAsyncContext, runWithAsyncContext } from './async_hooks.ts' import { basename, relative, resolve, sep } from './path.ts' import { requireActiveVfs } from '../../../storage/active.ts' import type { VfsBigIntStats, VfsMutation, VfsStats } from '../../../storage/types.ts' +import { abortError } from './abort-error.ts' type PathArg = string | URL | Uint8Array type WatchListener = (eventType: 'rename' | 'change', filename: string | Buffer | null) => void @@ -82,14 +83,6 @@ const contains = (parent: string, child: string): boolean => const overlaps = (left: string, right: string): boolean => contains(left, right) || contains(right, left) -const abortError = (reason?: unknown): Error & { code: string; cause?: unknown } => { - const error = new Error('The operation was aborted') as Error & { code: string } - error.name = 'AbortError' - error.code = 'ABORT_ERR' - if (reason !== undefined) error.cause = reason - return error -} - /** `fs.FSWatcher` over VFS mutations. */ export class FSWatcher extends EventEmitter { private readonly disposeMutation: () => void @@ -122,9 +115,8 @@ export class FSWatcher extends EventEmitter { this.signal = options.signal this.onAbort = options.signal === undefined ? undefined : () => { this.close() } if (options.signal?.aborted === true) { - this.disposeMutation() - this.closed = true - throw abortError(options.signal.reason) + this.close() + return } options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) } @@ -372,6 +364,10 @@ export function watchAsync( const onAbort = (): void => { settleFailure(abortError(options.signal?.reason)) } const start = (): void => { if (watcher !== undefined || closed || failure !== undefined) return + if (options.signal?.aborted === true) { + settleFailure(abortError(options.signal.reason)) + return + } try { watcher = watch(path, options, (eventType, filename) => { const event = { eventType, filename } diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 0aea178db4..4e83d52752 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -5,10 +5,13 @@ * file descriptors, `mkdtemp`, access checks, watchers, streams, and the promise face. */ import { requireActiveVfs } from '../../../storage/active.ts' -import type { Vfs, VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts' +import type { + Vfs, VfsBigIntStats, VfsOpenFile, VfsStatOptions, VfsStats, VfsWriteOptions, +} from '../../../storage/types.ts' import { Buffer } from 'buffer' import { Readable, Writable } from './stream.ts' import { dirname } from './path.ts' +import { abortError } from './abort-error.ts' import { FSWatcher, StatWatcher, unwatchFile, watch, watchAsync, watchFile, } from './fs-watch.ts' @@ -166,11 +169,14 @@ export function stat( const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback if (callback === undefined) throw new TypeError('The "callback" argument must be of type function') queueMicrotask(() => { + let result: VfsStats | VfsBigIntStats try { - callback(null, statSync(path, options)) + result = statSync(path, options) } catch (error) { callback(error as NodeJS.ErrnoException) + return } + callback(null, result) }) } @@ -290,9 +296,8 @@ export function accessSync(path: PathArg): void { } interface OpenFile { - path: string + file: VfsOpenFile position: number - append: boolean } const openFiles = new Map() @@ -308,25 +313,20 @@ let nextFd = 3 */ export function openSync(path: PathArg, flags = 'r', mode?: number): number { const target = asPath(path) - const exists = vfs().existsSync(target) - if (flags.includes('x') && exists) { - const error = new Error(`EEXIST: file already exists, open '${target}'`) as Error & { code: string; path: string } - error.code = 'EEXIST' - error.path = target - throw error - } - if (flags.startsWith('r')) vfs().realpathSync(target) - else if (flags.startsWith('w') || !exists) { - vfs().writeFileSync(target, new Uint8Array(0), mode === undefined ? undefined : { mode }) - } + const file = vfs().openFileSync(target, flags, mode) const fd = nextFd++ - openFiles.set(fd, { path: target, position: 0, append: flags.startsWith('a') }) + openFiles.set(fd, { file, position: 0 }) return fd } const fileOf = (fd: number, syscall: string): OpenFile => { const file = openFiles.get(fd) - if (file === undefined) throw new Error(`EBADF: bad file descriptor, ${syscall}`) + if (file === undefined) { + const error = new Error(`EBADF: bad file descriptor, ${syscall}`) as Error & { code: string; syscall: string } + error.code = 'EBADF' + error.syscall = syscall + throw error + } return file } @@ -347,9 +347,8 @@ export function readSync( position: number | null = null, ): number { const file = fileOf(fd, 'read') - const bytes = bytesOf(file.path) const from = position ?? file.position - const slice = bytes.subarray(from, from + length) + const slice = file.file.read(from, length) buffer.set(slice, offset) if (position === null) file.position = from + slice.byteLength return slice.byteLength @@ -364,17 +363,10 @@ export function readSync( export function writeSync(fd: number, data: string | Uint8Array): number { const file = fileOf(fd, 'write') const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data - if (file.append) { - vfs().appendFileSync(file.path, bytes) - return bytes.byteLength - } - const existing = vfs().existsSync(file.path) ? bytesOf(file.path) : new Uint8Array(0) - const merged = new Uint8Array(Math.max(existing.byteLength, file.position + bytes.byteLength)) - merged.set(existing, 0) - merged.set(bytes, file.position) - vfs().writeFileSync(file.path, merged) - file.position += bytes.byteLength - return bytes.byteLength + const position = file.file.append ? file.file.stat().size : file.position + const bytesWritten = file.file.write(position, bytes) + file.position = position + bytesWritten + return bytesWritten } /** @@ -382,17 +374,16 @@ export function writeSync(fd: number, data: string | Uint8Array): number { * @param fd - descriptor. */ export function closeSync(fd: number): void { - openFiles.delete(fd) + if (!openFiles.delete(fd)) fileOf(fd, 'close') } /** - * Create a second name for one file's contents. Hard links do not exist in the - * VFS, so the bytes are copied. + * Create a second name for one file identity. * @param from - existing path. * @param to - new path. */ export function linkSync(from: PathArg, to: PathArg): void { - writeFileSync(to, bytesOf(asPath(from))) + vfs().linkSync(asPath(from), asPath(to)) } /** @@ -424,30 +415,40 @@ export interface FileHandle { export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileHandle { const target = asPath(path) const directory = vfs().existsSync(target) && vfs().statSync(target).isDirectory() - const append = flags.startsWith('a') const fd = directory ? -1 : openSync(target, flags, mode) + let closed = false + const descriptor = (syscall: string): OpenFile => fileOf(fd, syscall) return { fd, - readFile: async (options?: EncodingOption) => readFileSync(target, options), - // Node appends when the handle was opened with 'a'. The JSONL session log - // depends on it — `open(path, 'a')` then `writeFile(batch)` — and replacing - // the file there destroys the header frame its reader requires. + readFile: async (options?: EncodingOption) => { + if (directory) return readFileSync(target, options) + const open = descriptor('read') + const bytes = open.file.read(open.position, Math.max(0, open.file.stat().size - open.position)) + open.position += bytes.length + const encoding = encodingOf(options) + return encoding === undefined || encoding === 'utf8' || encoding === 'utf-8' + ? (encoding === undefined ? asBuffer(bytes) : new TextDecoder().decode(bytes)) + : asBuffer(bytes).toString(encoding) + }, writeFile: async (data: string | Uint8Array) => { - if (append) appendFileSync(target, data) - else writeFileSync(target, data) + if (directory) writeFileSync(target, data) + else writeSync(fd, data) }, write: async (data: string | Uint8Array) => ({ bytesWritten: writeSync(fd, data) }), read: async (buffer: Uint8Array, offset = 0, length = buffer.byteLength, position: number | null = null) => ({ bytesRead: readSync(fd, buffer, offset, length, position), buffer, }), - stat: async () => statSync(target) as VfsStats, + stat: async () => directory ? statSync(target) as VfsStats : descriptor('fstat').file.stat(), truncate: async (length = 0) => { - writeFileSync(target, bytesOf(target).subarray(0, length)) + if (directory) writeFileSync(target, new Uint8Array(length)) + else descriptor('ftruncate').file.truncate(length) }, sync: async () => { await vfs().flush() }, datasync: async () => { await vfs().flush() }, close: async () => { + if (closed) return + closed = true if (fd !== -1) closeSync(fd) }, } @@ -477,12 +478,8 @@ export interface WriteStreamOptions { signal?: AbortSignal } -const aborted = (reason?: unknown): Error => { - const error = new Error('The operation was aborted', { cause: reason }) as Error & { code: string } - error.name = 'AbortError' - error.code = 'ABORT_ERR' - return error -} +/** Node implements file-stream `autoClose` through the stream's `autoDestroy` state. */ +const streamAutoDestroy = (autoClose: boolean | undefined): boolean => autoClose ?? true /** Read stream over one VFS file. */ export class ReadStream extends Readable { @@ -503,7 +500,7 @@ export class ReadStream extends Readable { constructor(path: PathArg, options: ReadStreamOptions = {}) { super({ - autoDestroy: options.autoClose ?? true, + autoDestroy: streamAutoDestroy(options.autoClose), emitClose: options.emitClose ?? true, highWaterMark: options.highWaterMark ?? 64 * 1024, }) @@ -513,7 +510,7 @@ export class ReadStream extends Readable { this.flags = options.flags ?? 'r' this.position = this.start this.signal = options.signal - this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(abortError(options.signal?.reason)) } if (options.encoding !== undefined && options.encoding !== null) this.setEncoding(options.encoding) options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) } @@ -524,7 +521,7 @@ export class ReadStream extends Readable { return } if (this.signal?.aborted === true) { - callback(aborted(this.signal.reason)) + callback(abortError(this.signal.reason)) return } try { @@ -598,7 +595,7 @@ export class WriteStream extends Writable { constructor(path: PathArg, options: WriteStreamOptions = {}) { super({ - autoDestroy: options.autoClose ?? true, + autoDestroy: streamAutoDestroy(options.autoClose), decodeStrings: true, defaultEncoding: options.encoding ?? 'utf8', emitClose: options.emitClose ?? true, @@ -609,7 +606,7 @@ export class WriteStream extends Writable { this.mode = options.mode this.start = options.start this.signal = options.signal - this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(aborted(options.signal?.reason)) } + this.onAbort = options.signal === undefined ? undefined : () => { this.destroy(abortError(options.signal?.reason)) } options.signal?.addEventListener('abort', this.onAbort as () => void, { once: true }) } @@ -619,7 +616,7 @@ export class WriteStream extends Writable { return } if (this.signal?.aborted === true) { - callback(aborted(this.signal.reason)) + callback(abortError(this.signal.reason)) return } try { @@ -772,13 +769,12 @@ export const promises = { mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, bytesOf(source)) }, - // The VFS has no inodes, so a hard link is a byte copy: the caller's contract - // is only that both names read the same content until one is removed. + // The VFS keeps both names attached to one file identity until either name is removed. link: async (from: PathArg, to: PathArg): Promise => { linkSync(from, to) }, open: async (path: PathArg, flags?: string, mode?: number): Promise => openHandleSync(path, flags, mode), opendir: async (path: PathArg): Promise => opendirSync(path), truncate: async (path: PathArg, length = 0): Promise => { - writeFileSync(path, bytesOf(asPath(path)).subarray(0, length)) + vfs().truncateSync(asPath(path), length) }, watch: watchAsync, constants, diff --git a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts index 319ee6b983..03ff455c64 100644 --- a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts +++ b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts @@ -51,7 +51,8 @@ export function parseLandlockArguments(args: readonly string[]): LandlockInvocat /** Map the host launcher's temp path into the Worker VFS. */ function vfsPath(path: string, cwd: string): string { - const absolute = resolve(cwd, path) + const resolved = resolve(cwd, path) + const absolute = resolved.length > 1 ? resolved.replace(/\/+$/u, '') : resolved if (absolute === '/tmp') return DSH_TMP if (absolute.startsWith('/tmp/')) return `${DSH_TMP}${absolute.slice('/tmp'.length)}` return absolute diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 6f41fbc9ee..7502e90bb8 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -8,7 +8,7 @@ import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-p import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts' import { parseTar } from './tar.ts' import type { - Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, + Vfs, VfsBigIntStats, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation, VfsOpenFile, VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats, VfsWriteOptions, } from './types.ts' @@ -20,6 +20,8 @@ interface FileNode { mtimeMs: number /** Permission bits (`0o777` mask), set at creation and changed only by `chmod`. */ mode: number + /** Stable identity shared by hard links and retained by open descriptors. */ + identity?: bigint } /** Creation default for files, Node's `0o666` under the classic `022` umask. */ @@ -80,7 +82,14 @@ function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, * @param mode - Stored permission bits of the entry. * @returns Stats in the shape Node returns under `{ bigint: true }`. */ -function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsBigIntStats { +function bigIntStatsOf( + size: number, + mtimeMs: number, + directory: boolean, + ino: bigint, + mode: number, + nlink = 1, +): VfsBigIntStats { const milliseconds = BigInt(Math.trunc(mtimeMs)) const nanoseconds = milliseconds * 1_000_000n const time = new Date(mtimeMs) @@ -89,7 +98,7 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b mode: BigInt((directory ? 0o040000 : 0o100000) | (mode & 0o777)), dev: 1n, ino, - nlink: 1n, + nlink: BigInt(nlink), mtimeMs: milliseconds, mtimeNs: nanoseconds, ctimeMs: milliseconds, @@ -112,6 +121,49 @@ function bigIntStatsOf(size: number, mtimeMs: number, directory: boolean, ino: b } } +interface OpenMode { + readonly readable: boolean + readonly writable: boolean + readonly append: boolean + readonly create: boolean + readonly truncate: boolean + readonly exclusive: boolean +} + +/** Parse the Node string flags supported by the compatibility filesystem. */ +function openMode(flags: string): OpenMode { + const base = flags[0] + const suffix = flags.slice(1).split('') + const validSuffix = suffix.every(flag => flag === '+' || flag === 'x' || flag === 's') + const uniqueSuffix = new Set(suffix).size === suffix.length + if ((base !== 'r' && base !== 'w' && base !== 'a') || !validSuffix || !uniqueSuffix + || base === 'r' && flags.includes('x')) { + const error = new TypeError(`The argument 'flags' is invalid. Received '${flags}'`) as TypeError & { code: string } + error.code = 'ERR_INVALID_ARG_VALUE' + throw error + } + return { + readable: base === 'r' || flags.includes('+'), + writable: base !== 'r' || flags.includes('+'), + append: base === 'a', + create: base === 'w' || base === 'a', + truncate: base === 'w', + exclusive: flags.includes('x'), + } +} + +/** Resize bytes exactly, preserving the prefix and zero-filling growth. */ +function resize(bytes: Uint8Array, length: number): Uint8Array { + if (!Number.isSafeInteger(length) || length < 0) { + const error = new RangeError(`The value of "len" is out of range. It must be >= 0. Received ${String(length)}`) as RangeError & { code: string } + error.code = 'ERR_OUT_OF_RANGE' + throw error + } + const resized = new Uint8Array(length) + resized.set(bytes.subarray(0, length)) + return resized +} + /** Construction inputs for {@link MemoryVfs}. */ export interface MemoryVfsOptions { /** Durable write-behind observer; absent leaves the filesystem ephemeral. */ @@ -133,9 +185,8 @@ export class MemoryVfs implements Vfs { private readonly mutationListeners = new Set() private readonly sink: VfsMutationSink | undefined private temporaries = 0 - // Identity per path, assigned on first stat and dropped when the path goes: - // the filesystem service builds its version token from `ino` plus the - // timestamp, so a recreated path must not look like the entry it replaced. + // Directories retain path identities. File identities live on FileNode so + // descriptors, renames, and hard links continue to address the same file. private readonly identities = new Map() private lastIdentity = 0n @@ -256,9 +307,9 @@ export class MemoryVfs implements Vfs { : this.directories.has(target) ? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const : fail('ENOENT', 'stat', target) - const identity = this.identityOf(target) + const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node) return options?.bigint === true - ? bigIntStatsOf(size, mtimeMs, directory, identity, mode) + ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.pathsOf(node).length) : statsOf(size, mtimeMs, directory, identity, mode) } @@ -276,7 +327,62 @@ export class MemoryVfs implements Vfs { return this.lastIdentity } - /** Forget a removed path's identity, so a recreated path reports a new one. */ + /** @returns The inode-like identity retained by a file node across names. */ + private identityOfFile(node: FileNode): bigint { + if (node.identity !== undefined) return node.identity + this.lastIdentity += 1n + node.identity = this.lastIdentity + return node.identity + } + + /** @returns Every currently linked path for one file node. */ + private pathsOf(node: FileNode): string[] { + const paths: string[] = [] + for (const [path, candidate] of this.files) { + if (candidate === node) paths.push(path) + } + return paths + } + + /** Publish a content or metadata write for every hard link to one node. */ + private publishFile(node: FileNode, appendedFrom?: number): void { + for (const path of this.pathsOf(node)) { + this.publish({ + kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false, + ...appendedFrom === undefined ? {} : { appendedFrom }, + }) + } + } + + /** Replace bytes on one file identity and notify all linked paths. */ + private replaceFile(node: FileNode, bytes: Uint8Array, appendedFrom?: number): void { + node.bytes = bytes + node.mtimeMs = this.touchNode(node) + this.publishFile(node, appendedFrom) + } + + /** Write at one offset, zero-filling any gap. */ + private writeFileNode(node: FileNode, position: number, data: Uint8Array): number { + const offset = Math.max(0, position) + const previousLength = node.bytes.length + const bytes = new Uint8Array(Math.max(previousLength, offset + data.length)) + bytes.set(node.bytes) + bytes.set(data, offset) + this.replaceFile(node, bytes, offset === previousLength ? previousLength : undefined) + return data.length + } + + /** Resize one file identity and notify all linked paths. */ + private truncateFile(node: FileNode, length: number): void { + this.replaceFile(node, resize(node.bytes, length)) + } + + /** @returns Plain stats for an open file, including after its last name is removed. */ + private fileStats(node: FileNode): VfsStats { + return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode) + } + + /** Forget removed directory identities, so recreated paths report new ones. */ private forgetIdentity(target: string): void { this.identities.delete(target) const prefix = `${target}${SEP}` @@ -296,7 +402,12 @@ export class MemoryVfs implements Vfs { * @returns Now, or one millisecond past the entry's current time. */ private touch(target: string): number { - const previous = this.files.get(target)?.mtimeMs + return this.touchNode(this.files.get(target)) + } + + /** @returns A modification time strictly newer than one file node's current value. */ + private touchNode(node?: FileNode): number { + const previous = node?.mtimeMs const now = Date.now() return previous === undefined ? now : Math.max(now, previous + 1) } @@ -402,11 +513,14 @@ export class MemoryVfs implements Vfs { const previous = this.files.get(target) const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE) const bytes = typeof data === 'string' ? encoder.encode(data) : data - this.files.set(target, { bytes, mtimeMs: this.touch(target), mode }) - if (previous === undefined) this.touchDirectory(dirname(target)) - this.publish({ - kind: 'write', path: target, bytes, mode, entryChanged: previous === undefined, - }) + if (previous !== undefined) { + this.replaceFile(previous, bytes) + return + } + const node: FileNode = { bytes, mtimeMs: this.touch(target), mode } + this.files.set(target, node) + this.touchDirectory(dirname(target)) + this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true }) } /** @@ -453,46 +567,89 @@ export class MemoryVfs implements Vfs { ...this.handleTail(target), } } - const exists = this.files.has(target) - if (flags.startsWith('r') && !exists) fail('ENOENT', 'open', target) - if (flags.startsWith('wx') && exists) fail('EEXIST', 'open', target) - if (!flags.startsWith('r') && !this.directories.has(dirname(target))) fail('ENOENT', 'open', target) - const creation = mode === undefined ? {} : { mode } - if (flags.startsWith('w') && !flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), creation) - if (flags.startsWith('wx')) this.writeFileSync(target, new Uint8Array(), { flag: 'wx', ...creation }) - if (flags.startsWith('a') && !exists) this.writeFileSync(target, new Uint8Array(), creation) - const appending = flags.startsWith('a') + const file = this.openFileSync(target, flags, mode) + let position = 0 + let closed = false + const current = (syscall: string): VfsOpenFile => { + if (closed) fail('EBADF', syscall, target) + return file + } return { write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => { const bytes = typeof data === 'string' ? encoder.encode(data) : data - this.appendFileSync(target, bytes) - return { bytesWritten: bytes.length } + const descriptor = current('write') + const offset = descriptor.append ? descriptor.stat().size : position + const bytesWritten = descriptor.write(offset, bytes) + position = offset + bytesWritten + return { bytesWritten } }, - // A handle opened for append must append here too: session persistence - // opens the log with `a` and writes each batch through this method, so a - // truncating write would replace the whole log with the newest batch. writeFile: async (data: string | Uint8Array): Promise => { - if (appending) this.appendFileSync(target, data) - else this.writeFileSync(target, data) + const bytes = typeof data === 'string' ? encoder.encode(data) : data + const descriptor = current('write') + const offset = descriptor.append ? descriptor.stat().size : position + position = offset + descriptor.write(offset, bytes) + }, + readFile: async (options?: VfsReadOptions): Promise => { + const descriptor = current('read') + const bytes = descriptor.read(position, Math.max(0, descriptor.stat().size - position)) + position += bytes.length + return encodingOf(options) === undefined ? bytes : decoder.decode(bytes) }, - readFile: async (options?: VfsReadOptions): Promise => this.readFileSync(target, options), truncate: async (length = 0): Promise => { - const node = this.files.get(target) - if (node === undefined) fail('ENOENT', 'ftruncate', target) - const bytes = node.bytes.slice(0, length) - this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) - this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) + current('ftruncate').truncate(length) }, - ...this.handleTail(target), + stat: async (): Promise => current('fstat').stat(), + sync: async (): Promise => { current('fsync'); await this.flush() }, + datasync: async (): Promise => { current('fdatasync'); await this.flush() }, + close: async (): Promise => { closed = true }, } } /** - * The handle members that do not depend on how the file was opened. - * + * Open one synchronous descriptor over a stable file identity. + * @param path - File path. + * @param flags - Node open flags. + * @param mode - Permission bits applied only when a file is created. + * @returns An open file that survives path rename, replacement, and unlink. + */ + openFileSync(path: string, flags = 'r', mode?: number): VfsOpenFile { + const target = this.key(path) + const access = openMode(flags) + const existing = this.files.get(target) + if (this.directories.has(target)) fail('EISDIR', 'open', target) + if (access.exclusive && existing !== undefined) fail('EEXIST', 'open', target) + if (!access.create && existing === undefined) fail('ENOENT', 'open', target) + if (access.create && existing === undefined) { + this.writeFileSync(target, new Uint8Array(), mode === undefined ? undefined : { mode }) + } else if (access.truncate && existing !== undefined) { + this.truncateFile(existing, 0) + } + const node = this.files.get(target) + if (node === undefined) fail('ENOENT', 'open', target) + return { + readable: access.readable, + writable: access.writable, + append: access.append, + read: (position, length) => { + if (!access.readable) fail('EBADF', 'read', target) + return node.bytes.subarray(position, position + length) + }, + write: (position, data) => { + if (!access.writable) fail('EBADF', 'write', target) + return this.writeFileNode(node, access.append ? node.bytes.length : position, data) + }, + truncate: (length) => { + if (!access.writable) fail('EINVAL', 'ftruncate', target) + this.truncateFile(node, length) + }, + stat: () => this.fileStats(node), + } + } + + /** + * Directory-handle members for metadata, durability, and release. * `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem - * resolves immediately. `close` releases nothing, so both directory and file - * handles share this tail. + * resolves immediately and `close` releases nothing. * @param target - Normalized path the handle was opened on. * @returns Metadata plus the no-op durability and release calls. */ @@ -515,14 +672,7 @@ export class MemoryVfs implements Vfs { const existing = this.files.get(target) const addition = typeof data === 'string' ? encoder.encode(data) : data if (existing === undefined) { this.writeFileSync(target, addition); return } - const merged = new Uint8Array(existing.bytes.length + addition.length) - merged.set(existing.bytes) - merged.set(addition, existing.bytes.length) - this.files.set(target, { bytes: merged, mtimeMs: this.touch(target), mode: existing.mode }) - this.publish({ - kind: 'write', path: target, bytes: merged, mode: existing.mode, - entryChanged: false, appendedFrom: existing.bytes.length, - }) + this.writeFileNode(existing, existing.bytes.length, addition) } /** @@ -533,8 +683,10 @@ export class MemoryVfs implements Vfs { renameSync(from: string, to: string): void { const source = this.key(from) const destination = this.key(to) + if (source === destination) return const node = this.files.get(source) if (node !== undefined) { + if (this.directories.has(destination)) fail('EISDIR', 'rename', destination) if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination) this.files.delete(source) this.files.set(destination, node) @@ -588,9 +740,8 @@ export class MemoryVfs implements Vfs { /** * Give existing bytes a second name. * - * There are no inodes here, so the two names share the bytes present at link - * time and diverge on the next write through either name; session persistence - * links a finished file to a stable name, which this satisfies. + * Both names retain one file identity, so writes and metadata changes through + * either name remain visible through the other until that name is removed. * @param existing - Source file path. * @param next - Additional path; its parent must exist and it must be free. */ @@ -615,9 +766,7 @@ export class MemoryVfs implements Vfs { const target = this.key(path) const node = this.files.get(target) if (node === undefined) fail('ENOENT', 'truncate', target) - const bytes = node.bytes.slice(0, length) - this.files.set(target, { bytes, mtimeMs: this.touch(target), mode: node.mode }) - this.publish({ kind: 'write', path: target, bytes, mode: node.mode, entryChanged: false }) + this.truncateFile(node, length) } /** @@ -630,7 +779,7 @@ export class MemoryVfs implements Vfs { const node = this.files.get(target) if (node !== undefined) { node.mode = mode & 0o777 - this.publish({ kind: 'chmod', path: target, mode: node.mode }) + for (const path of this.pathsOf(node)) this.publish({ kind: 'chmod', path, mode: node.mode }) return } if (this.directories.has(target)) { diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index e879d5f11f..f1482e07d6 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -1,8 +1,8 @@ /** * Filesystem interfaces shared by every VFS backend. The shipped implementation - * is in memory; a browser-persistent backend would implement the same faces. Errors carry - * Node's `code` values because roster plugins branch on them (`ENOENT` for - * optional files, `EACCES` for read-only trees). + * is in memory; browser persistence hydrates it and consumes its committed + * mutation stream. Errors carry Node's `code` values because roster plugins + * branch on them (`ENOENT` for optional files, `EACCES` for read-only trees). * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/types */ @@ -22,7 +22,7 @@ export interface VfsError extends Error { /** Subset of `fs.Stats` the roster reads. */ export interface VfsStats { readonly size: number - /** Stable identity while an entry exists; recreation receives another value. */ + /** Stable file identity across rename and hard links; recreation receives another value. */ readonly ino: number readonly mtimeMs: number readonly ctimeMs: number @@ -54,7 +54,7 @@ export interface VfsBigIntStats { readonly mode: bigint /** One virtual device holds the whole image. */ readonly dev: bigint - /** Identity of the entry at this path; a removed and recreated path gets a new one. */ + /** File identity retained across rename and hard links; recreation gets a new one. */ readonly ino: bigint readonly nlink: bigint readonly mtimeMs: bigint @@ -125,6 +125,40 @@ export interface VfsFileHandle { close(): Promise } +/** Open-file identity used by synchronous Node-style descriptors. */ +export interface VfsOpenFile { + /** Whether reads are allowed by the flags used at open time. */ + readonly readable: boolean + /** Whether writes and truncation are allowed by the flags used at open time. */ + readonly writable: boolean + /** Whether each write targets the current end of the opened file. */ + readonly append: boolean + /** + * Read bytes from the opened file identity. + * @param position - Absolute byte offset. + * @param length - Maximum byte count. + * @returns A view of the available bytes. + */ + read(position: number, length: number): Uint8Array + /** + * Write bytes to the opened file identity. + * @param position - Absolute byte offset, ignored for append descriptors. + * @param data - Bytes to write. + * @returns Number of bytes written. + */ + write(position: number, data: Uint8Array): number + /** + * Resize the opened file, zero-filling growth. + * @param length - Target byte length. + */ + truncate(length: number): void + /** + * Read metadata from the opened file identity. + * @returns Current file metadata, including after rename or unlink. + */ + stat(): VfsStats +} + /** * One completed change to the authoritative in-memory filesystem. * @@ -203,6 +237,8 @@ export interface Vfs { unlinkSync(path: string): void rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void mkdtempSync(prefix: string): string + /** Open and retain one file identity until its Node descriptor closes. */ + openFileSync(path: string, flags?: string, mode?: number): VfsOpenFile seed(path: string, data: string | Uint8Array, options?: VfsSeedOptions): void seedDirectory(path: string, options?: VfsSeedOptions): void usage(): { files: number; directories: number; bytes: number } diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index e61693e479..3c5304e8df 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -238,6 +238,14 @@ it('normalizes relative grants and denies sibling-prefix escapes and unreadable expect(result.stdout).not.toContain('private') }) +it('treats trailing-slash grants as the same subtree', async () => { + const invocation = parseLandlockArguments(['--rw', '/tmp/', '--', 'true']) + if (invocation.kind !== 'run') throw new Error('expected a confined run invocation') + const guarded = await landlockFileSystem(hostFileSystem(), invocation, WORKSPACE) + await guarded.writeText('/tmp/nested.txt', 'allowed') + expect(vfs.readFileSync(`${TMP}/nested.txt`, 'utf8')).toBe('allowed') +}) + it('presents the virtual device directory without storing it in the VFS', async () => { const child = spawn(launcherPath(), [ ...grantArgs({ readOnly: ['/'], readWrite: ['/dev/null'] }), diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts index e095d2b6f2..c5759dd4d7 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -1,12 +1,17 @@ /** Node differential checks for the Worker filesystem watcher and stream faces. */ import { + closeSync as closeNodeSync, createReadStream as createNodeReadStream, createWriteStream as createNodeWriteStream, mkdtempSync, + openSync as openNodeSync, + readSync as readNodeSync, readFileSync, + renameSync as renameNodeSync, rmSync, unwatchFile as unwatchNodeFile, watchFile as watchNodeFile, + writeSync as writeNodeSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -106,6 +111,139 @@ async function writeScenario(create: () => WritableFileStream): Promise<{ } describe('file streams', () => { + it('keeps an opened file identity across rename, replacement, and unlink', () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'identity.txt') + const workerPath = `${VFS_ROOT}/identity.txt` + + const nativeScenario = (): string[] => { + writeFileSync(nativePath, 'original') + const fd = openNodeSync(nativePath, 'r') + renameNodeSync(nativePath, `${nativePath}.moved`) + writeFileSync(nativePath, 'replacement') + const beforeUnlink = Buffer.alloc(16) + const firstCount = readNodeSync(fd, beforeUnlink, 0, beforeUnlink.length, 0) + rmSync(`${nativePath}.moved`) + const afterUnlink = Buffer.alloc(16) + const secondCount = readNodeSync(fd, afterUnlink, 0, afterUnlink.length, 0) + closeNodeSync(fd) + return [beforeUnlink.subarray(0, firstCount).toString(), afterUnlink.subarray(0, secondCount).toString()] + } + const workerScenario = (): string[] => { + vfs.writeFileSync(workerPath, 'original') + const fd = workerFs.openSync(workerPath, 'r') + vfs.renameSync(workerPath, `${workerPath}.moved`) + vfs.writeFileSync(workerPath, 'replacement') + const beforeUnlink = Buffer.alloc(16) + const firstCount = workerFs.readSync(fd, beforeUnlink, 0, beforeUnlink.length, 0) + vfs.rmSync(`${workerPath}.moved`) + const afterUnlink = Buffer.alloc(16) + const secondCount = workerFs.readSync(fd, afterUnlink, 0, afterUnlink.length, 0) + workerFs.closeSync(fd) + return [beforeUnlink.subarray(0, firstCount).toString(), afterUnlink.subarray(0, secondCount).toString()] + } + + expect(workerScenario()).toEqual(nativeScenario()) + }) + + it('keeps a read stream on the file opened before an atomic replacement', async () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'stream-identity.txt') + const workerPath = `${VFS_ROOT}/stream-identity.txt` + writeFileSync(nativePath, 'original') + vfs.writeFileSync(workerPath, 'original') + + const readAfterReplacement = async ( + stream: AsyncIterable & { once(event: string, listener: () => void): unknown }, + replace: () => void, + ): Promise => { + stream.once('open', replace) + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + return Buffer.concat(chunks).toString() + } + const native = await readAfterReplacement(createNodeReadStream(nativePath, { highWaterMark: 2 }), () => { + renameNodeSync(nativePath, `${nativePath}.moved`) + writeFileSync(nativePath, 'replacement') + }) + const worker = await readAfterReplacement(workerFs.createReadStream(workerPath, { highWaterMark: 2 }), () => { + vfs.renameSync(workerPath, `${workerPath}.moved`) + vfs.writeFileSync(workerPath, 'replacement') + }) + expect(worker).toBe(native) + }) + + it('rejects descriptor operations that conflict with the open mode', () => { + const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) + nativeRoots.push(nativeRoot) + const nativePath = join(nativeRoot, 'mode.txt') + const workerPath = `${VFS_ROOT}/mode.txt` + writeFileSync(nativePath, 'content') + vfs.writeFileSync(workerPath, 'content') + const codeOf = (run: () => unknown): string | undefined => { + try { + run() + return undefined + } catch (error) { + return (error as NodeJS.ErrnoException).code + } + } + + const nativeReadOnly = openNodeSync(nativePath, 'r') + const workerReadOnly = workerFs.openSync(workerPath, 'r') + expect(codeOf(() => workerFs.writeSync(workerReadOnly, 'x'))) + .toBe(codeOf(() => writeNodeSync(nativeReadOnly, 'x'))) + closeNodeSync(nativeReadOnly) + workerFs.closeSync(workerReadOnly) + + const nativeWriteOnly = openNodeSync(nativePath, 'w') + const workerWriteOnly = workerFs.openSync(workerPath, 'w') + expect(codeOf(() => workerFs.readSync(workerWriteOnly, Buffer.alloc(1), 0, 1, 0))) + .toBe(codeOf(() => readNodeSync(nativeWriteOnly, Buffer.alloc(1), 0, 1, 0))) + closeNodeSync(nativeWriteOnly) + workerFs.closeSync(workerWriteOnly) + }) + + it('keeps hard-link identity and content shared through the Node face', () => { + const source = `${VFS_ROOT}/linked-source.txt` + const alias = `${VFS_ROOT}/linked-alias.txt` + workerFs.writeFileSync(source, 'one') + workerFs.linkSync(source, alias) + expect(workerFs.statSync(alias, { bigint: true }).ino) + .toBe(workerFs.statSync(source, { bigint: true }).ino) + workerFs.appendFileSync(alias, '-two') + expect(workerFs.readFileSync(source, 'utf8')).toBe('one-two') + }) + + it('reports incompatible read and write stream flags as EBADF', async () => { + const path = `${VFS_ROOT}/stream-mode.txt` + vfs.writeFileSync(path, 'content') + const writeError = nextValue((resolve) => { + const stream = workerFs.createWriteStream(path, { flags: 'r' }) + stream.once('error', resolve) + stream.end('x') + }) + await expect(writeError).resolves.toMatchObject({ code: 'EBADF' }) + + const read = workerFs.createReadStream(path, { flags: 'w' }) + const readError = nextValue((resolve) => { read.once('error', resolve) }) + read.resume() + await expect(readError).resolves.toMatchObject({ code: 'EBADF' }) + }) + + it('zero-extends through promise and file-handle truncate', async () => { + const path = `${VFS_ROOT}/truncate.txt` + vfs.writeFileSync(path, new Uint8Array([1, 2])) + await workerFsp.truncate(path, 4) + expect([...workerFs.readFileSync(path) as Uint8Array]).toEqual([1, 2, 0, 0]) + const handle = await workerFsp.open(path, 'r+') + await handle.truncate(6) + await handle.close() + expect([...workerFs.readFileSync(path) as Uint8Array]).toEqual([1, 2, 0, 0, 0, 0]) + }) + it('matches Node chunking, inclusive ranges, and read lifecycle ordering', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) nativeRoots.push(nativeRoot) @@ -175,6 +313,51 @@ describe('file streams', () => { expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) }) + it('keeps autoClose false descriptors open until explicit stream close', async () => { + const readPath = `${VFS_ROOT}/manual-read-close.txt` + vfs.writeFileSync(readPath, 'content') + const read = workerFs.createReadStream(readPath, { autoClose: false }) + read.resume() + await nextValue((resolve) => { read.once('end', () => { resolve(undefined) }) }) + const readFd = read.fd + expect(readFd).not.toBeNull() + expect(read.destroyed).toBe(false) + expect(() => workerFs.readSync(readFd as number, Buffer.alloc(1), 0, 1, 0)).not.toThrow() + const readClosed = nextValue((resolve) => { read.once('close', () => { resolve(undefined) }) }) + read.close() + await readClosed + expect(() => workerFs.readSync(readFd as number, Buffer.alloc(1), 0, 1, 0)).toThrow(/EBADF/) + + const write = workerFs.createWriteStream(`${VFS_ROOT}/manual-write-close.txt`, { autoClose: false }) + write.end('a') + await nextValue((resolve) => { write.once('finish', () => { resolve(undefined) }) }) + const writeFd = write.fd + expect(writeFd).not.toBeNull() + expect(write.destroyed).toBe(false) + expect(workerFs.writeSync(writeFd as number, 'b')).toBe(1) + const writeClosed = nextValue((resolve) => { write.once('close', () => { resolve(undefined) }) }) + write.close() + await writeClosed + expect(workerFs.readFileSync(`${VFS_ROOT}/manual-write-close.txt`, 'utf8')).toBe('ab') + + vfs.writeFileSync(`${VFS_ROOT}/manual-error-close.txt`, 'content') + const errored = workerFs.createWriteStream(`${VFS_ROOT}/manual-error-close.txt`, { + flags: 'r', + autoClose: false, + }) + const error = nextValue((resolve) => { errored.once('error', resolve) }) + errored.end('rejected') + await expect(error).resolves.toMatchObject({ code: 'EBADF' }) + const errorFd = errored.fd + expect(errorFd).not.toBeNull() + expect(errored.destroyed).toBe(false) + expect(() => workerFs.readSync(errorFd as number, Buffer.alloc(1), 0, 1, 0)).not.toThrow() + const errorClosed = nextValue((resolve) => { errored.once('close', () => { resolve(undefined) }) }) + errored.destroy() + await errorClosed + expect(() => workerFs.readSync(errorFd as number, Buffer.alloc(1), 0, 1, 0)).toThrow(/EBADF/) + }) + it('matches Node positional overwrite and missing-file failure', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) nativeRoots.push(nativeRoot) @@ -258,6 +441,22 @@ async function watchFileScenario( } describe('watchers', () => { + it('does not catch exceptions thrown by a successful stat callback', () => { + const path = `${VFS_ROOT}/callback.txt` + vfs.writeFileSync(path, 'value') + const failure = new Error('callback failed') + let calls = 0 + const dispatch = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((callback) => { callback() }) + expect(() => { + workerFs.stat(path, () => { + calls += 1 + throw failure + }) + }).toThrow(failure) + expect(calls).toBe(1) + dispatch.mockRestore() + }) + it('matches Node watchFile state transitions for a missing and recreated file', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-watch-diff-')) nativeRoots.push(nativeRoot) @@ -398,16 +597,20 @@ describe('watchers', () => { await expect(event).resolves.toEqual(['rename', 'file.txt']) }) - it('rejects an already-aborted callback watcher without retaining a subscription', () => { + it('returns an asynchronously closing watcher for a pre-aborted signal', async () => { const controller = new AbortController() - const reason = new Error('already stopped') - controller.abort(reason) - try { - workerFs.watch(VFS_ROOT, { signal: controller.signal }) - throw new Error('watch unexpectedly opened') - } catch (error) { - expect(error).toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) - } + controller.abort(new Error('already stopped')) + const order: string[] = [] + const watcher = workerFs.watch(VFS_ROOT, { signal: controller.signal }) + const closed = nextValue((resolve) => { + watcher.once('close', () => { + order.push('close') + resolve(undefined) + }) + }) + order.push('return') + await closed + expect(order).toEqual(['return', 'close']) expect(() => { vfs.writeFileSync(`${VFS_ROOT}/after-abort.txt`, 'x') }).not.toThrow() }) @@ -474,6 +677,14 @@ describe('watchers', () => { await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) }) + it('rejects the first promise-watch read for a pre-aborted signal', async () => { + const controller = new AbortController() + const reason = new Error('already stopped') + controller.abort(reason) + const iterator = workerFsp.watch(VFS_ROOT, { signal: controller.signal })[Symbol.asyncIterator]() + await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) + }) + it('lets promise-watch return interrupt a pending next call', async () => { const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() const pending = iterator.next() diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 8278e6c153..069da0ca2a 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -40,10 +40,7 @@ describe('entry identity', () => { expect(identity(vfs, '/dsh/skills/git/SKILL.md')).not.toBe(before) }) - it('assigns the destination of a rename an identity of its own', () => { - // Identity belongs to the path, not to the bytes: a renamed-over path must - // stop looking like the entry it replaced, which is the property the guard - // reads. The source identity deliberately does not follow the move. + it('moves the source identity when a file replaces another path', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/from.txt', 'moved') vfs.seed('/dsh/to.txt', 'replaced') @@ -51,7 +48,7 @@ describe('entry identity', () => { vfs.renameSync('/dsh/from.txt', '/dsh/to.txt') const renamed = identity(vfs, '/dsh/to.txt') expect(vfs.readFileSync('/dsh/to.txt', 'utf8')).toBe('moved') - expect([renamed === source, renamed === destination]).toEqual([false, false]) + expect([renamed === source, renamed === destination]).toEqual([true, false]) }) }) @@ -92,6 +89,16 @@ describe('modification time', () => { expect(modified(vfs, '/dsh/log.jsonl')).toBe(1_700_000_005_000) }) + it('extends truncation with zero bytes', async () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/file', new Uint8Array([1, 2])) + vfs.truncateSync('/dsh/file', 5) + expect([...vfs.readFileSync('/dsh/file') as Uint8Array]).toEqual([1, 2, 0, 0, 0]) + const handle = vfs.open('/dsh/file', 'r+') + await handle.truncate(7) + expect([...vfs.readFileSync('/dsh/file') as Uint8Array]).toEqual([1, 2, 0, 0, 0, 0, 0]) + }) + it('advances a directory only when its immediate entry set changes', () => { vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) const vfs = new MemoryVfs() @@ -175,6 +182,24 @@ describe('mutation publication', () => { expect(flushes).toBe(1) }) + it('publishes descriptor writes at the file identity current path', () => { + const mutations: VfsMutation[] = [] + const vfs = new MemoryVfs() + vfs.seed('/dsh/source', 'old') + const descriptor = vfs.openFileSync('/dsh/source', 'r+') + vfs.subscribe((mutation) => { mutations.push(mutation) }) + vfs.renameSync('/dsh/source', '/dsh/destination') + mutations.length = 0 + descriptor.write(0, new TextEncoder().encode('new')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/destination']) + expect(vfs.readFileSync('/dsh/destination', 'utf8')).toBe('new') + vfs.unlinkSync('/dsh/destination') + mutations.length = 0 + descriptor.write(0, new TextEncoder().encode('detached')) + expect(mutations).toEqual([]) + expect(new TextDecoder().decode(descriptor.read(0, descriptor.stat().size))).toBe('detached') + }) + it('decomposes a directory rename into replayable destination state', () => { const recorded: VfsMutation[] = [] const vfs = new MemoryVfs({ @@ -196,13 +221,30 @@ describe('mutation publication', () => { }) describe('hard links', () => { - it('shares the bytes present at link time and diverges on the next write', () => { + it('shares identity, bytes, and mode until one name is removed', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/session.jsonl', 'committed\n') vfs.linkSync('/dsh/session.jsonl', '/dsh/session-latest.jsonl') + expect(identity(vfs, '/dsh/session-latest.jsonl')).toBe(identity(vfs, '/dsh/session.jsonl')) expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\n') + const changedPaths: string[] = [] + vfs.subscribe((mutation) => { changedPaths.push(mutation.path) }) vfs.appendFileSync('/dsh/session.jsonl', 'appended\n') + expect(changedPaths).toEqual(['/dsh/session.jsonl', '/dsh/session-latest.jsonl']) expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') - expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\n') + expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\nappended\n') + vfs.chmodSync('/dsh/session-latest.jsonl', 0o600) + expect((vfs.statSync('/dsh/session.jsonl') as VfsStats).mode & 0o777).toBe(0o600) + vfs.unlinkSync('/dsh/session-latest.jsonl') + expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') + }) + + it('rejects renaming a file over an existing directory', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/file', 'value') + vfs.seedDirectory('/dsh/directory') + expect(() => { vfs.renameSync('/dsh/file', '/dsh/directory') }).toThrow(expect.objectContaining({ code: 'EISDIR' })) + expect(vfs.readFileSync('/dsh/file', 'utf8')).toBe('value') + expect(vfs.statSync('/dsh/directory').isDirectory()).toBe(true) }) }) From be852d4e9bd2adb4f1d317bc1f488533e1a5a62a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:01:19 +0800 Subject: [PATCH 15/21] fix(webworker): scope Linux-only CI checks --- .../node/builtin_modules/implemented/fs.ts | 57 ++++++++++++------- .../tests/node/fs-watch-stream.spec.ts | 2 +- vitest.config.ts | 17 +++++- 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 4e83d52752..e4d7099f3e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -481,6 +481,40 @@ export interface WriteStreamOptions { /** Node implements file-stream `autoClose` through the stream's `autoDestroy` state. */ const streamAutoDestroy = (autoClose: boolean | undefined): boolean => autoClose ?? true +interface FileStreamState { + fd: number | null + pending: boolean +} + +/** Release the descriptor and abort listener shared by both file-stream directions. */ +function destroyFileStream( + stream: FileStreamState, + signal: AbortSignal | undefined, + onAbort: (() => void) | undefined, + error: Error | null, + callback: (error: Error | null) => void, +): void { + signal?.removeEventListener('abort', onAbort as () => void) + if (stream.fd !== null) closeSync(stream.fd) + stream.fd = null + stream.pending = false + callback(error) +} + +interface ClosableFileStream { + once(event: string, listener: () => void): unknown + destroy(): unknown +} + +/** Register an optional completion callback and explicitly destroy a file stream. */ +function closeFileStream( + stream: ClosableFileStream, + callback?: (error?: NodeJS.ErrnoException | null) => void, +): void { + if (callback !== undefined) stream.once('close', () => { callback(null) }) + stream.destroy() +} + /** Read stream over one VFS file. */ export class ReadStream extends Readable { /** Resolved path opened by this stream. */ @@ -560,11 +594,7 @@ export class ReadStream extends Readable { } override _destroy(error: Error | null, callback: (error?: Error | null) => void): void { - this.signal?.removeEventListener('abort', this.onAbort as () => void) - if (this.fd !== null) closeSync(this.fd) - this.fd = null - this.pending = false - callback(error) + destroyFileStream(this, this.signal, this.onAbort, error, callback) } /** @@ -572,8 +602,7 @@ export class ReadStream extends Readable { * @param callback - Optional completion callback after `close`. */ close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { - if (callback !== undefined) this.once('close', () => { callback(null) }) - this.destroy() + closeFileStream(this, callback) } } @@ -647,11 +676,7 @@ export class WriteStream extends Writable { } override _destroy(error: Error | null, callback: (error: Error | null) => void): void { - this.signal?.removeEventListener('abort', this.onAbort as () => void) - closeDescriptor(this.fd) - this.fd = null - this.pending = false - callback(error) + destroyFileStream(this, this.signal, this.onAbort, error, callback) } /** @@ -659,16 +684,10 @@ export class WriteStream extends Writable { * @param callback - Optional completion callback after `close`. */ close(callback?: (error?: NodeJS.ErrnoException | null) => void): void { - if (callback !== undefined) this.once('close', () => { callback(null) }) - this.destroy() + closeFileStream(this, callback) } } -/** Close a stream-owned descriptor when it has opened successfully. */ -function closeDescriptor(fd: number | null): void { - if (fd !== null) closeSync(fd) -} - /** * Create a Node-compatible readable file stream over the VFS. * @param path - File path. diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts index c5759dd4d7..df8c31e0b5 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -279,7 +279,7 @@ describe('file streams', () => { expect(workerStream.default._isArrayBufferView(new Uint8Array())).toBe(true) }) - it('matches Node file-stream defaults and abort error identity', async () => { + it('uses Node 22 Linux file-stream defaults and abort error identity', async () => { const nativeRoot = mkdtempSync(join(tmpdir(), 'dsh-stream-diff-')) nativeRoots.push(nativeRoot) const nativePath = join(nativeRoot, 'input.txt') diff --git a/vitest.config.ts b/vitest.config.ts index f24c8c552e..6e870fcbe4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,6 +57,17 @@ const windowsUnsupportedTests = process.platform === 'win32' ] : [] +// These suites compare against or assemble the Worker's fixed Linux platform. +// Host-native Windows and macOS behavior is not their oracle. +const nonLinuxWebWorkerTests = process.platform === 'linux' + ? [] + : [ + 'packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts', + 'packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts', + ] + +const platformUnsupportedTests = [...windowsUnsupportedTests, ...nonLinuxWebWorkerTests] + const windowsUnsupportedCoveragePackages = process.platform === 'win32' ? [...windowsUnsupportedPackages, 'packages/subprocess/*'] : [] @@ -142,7 +153,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, - exclude: windowsUnsupportedTests, + exclude: platformUnsupportedTests, // One coverage invocation aggregates both projects. Every suite forks for // Node stability; process-bound suites stay separate for inventory control. projects: [ @@ -158,7 +169,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ - ...windowsUnsupportedTests, + ...platformUnsupportedTests, ...processBoundTests, ...coverageExemptExcludes, ], @@ -173,7 +184,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, exclude: [ - ...windowsUnsupportedTests, + ...platformUnsupportedTests, ...coverageExemptExcludes, ], }, From 8aa222a40d7952699bf6287fc1bb387bea19c0ec Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:12:27 +0800 Subject: [PATCH 16/21] test(web): await subagent history before snapshot --- apps/web/tests/subagent-interrupt-ui.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 592d55abe6..0eca3016fd 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -203,6 +203,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co name: 'Parent session offline; sending is unavailable but you can still stop the run', }) await input.waitFor({ timeout: 15_000 }) + await page.getByText(INITIAL, { exact: true }).waitFor({ timeout: 15_000 }) expect(await input.isDisabled()).toBe(true) const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) @@ -247,7 +248,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open') expect(scaffold.ctx.agents.get(childId)?.status).toBe('running') } finally { - await page.unroute(pattern) + await page.unrouteAll({ behavior: 'wait' }) } }, 60_000) From 91b545daf52166e8ce6215d3aa279c764c84e31d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:31:20 +0800 Subject: [PATCH 17/21] fix(webworker): support package inventory resolution --- .../2026-08-20-webworker-node-face.i18n.yaml | 4 +- .../2026-08-20-webworker-node-face.md | 2 +- .../2026-08-20-webworker-node-face.zh.md | 2 +- .../tests/image-loadable.spec.ts | 66 +++++++++++++++++++ .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 2 +- .../webworker-runtime/README.zh.md | 2 +- .../src/module-system/module-loader.ts | 53 +++++++++++---- .../builtin_modules/implemented/module.ts | 7 +- .../tests/node/builtins-table.spec.ts | 12 +++- 10 files changed, 127 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml index 47269a11c8..bf06cd8dee 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md -2026-08-20-webworker-node-face.md: 6e69af83354f1139a03d047a700e84e7e918a013 -2026-08-20-webworker-node-face.zh.md: 96a60e459372828273b3a4d1330a7b7eb8f2994f +2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81 +2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md index 6e69af8335..41a30dedc7 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md @@ -10,7 +10,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work ## Decision -**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly. +**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. **VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md index 96a60e4593..b574813358 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md @@ -10,7 +10,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属 ## 决定 -**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。 +**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。 **VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。 diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 609e3ccf80..766f974735 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -20,12 +20,14 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { FiberState } from '@deepseek-ai/cordis' import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts' import { setActiveModuleLoader, WorkerModuleLoader, } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts' import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts' import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts' +import { setActiveVfs } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/active.ts' import { indexWorkspacePackages, previewFixtures } from '../src/repository.ts' import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage, packVfsOverlay } from '../src/pack.ts' @@ -34,6 +36,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) /** A leaf workspace package: real build output, no dependencies to drag in. */ const SUBJECT = '@deepseek-ai/dsh-timeout' const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' +const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek' const workspaces = indexWorkspacePackages(repoRoot) @@ -91,6 +94,15 @@ const packedLandlock = (): ReturnType => landlockMemo ??= p entries: [], }) +let pluginInventoryMemo: ReturnType | undefined +const packedPluginInventory = (): ReturnType => pluginInventoryMemo ??= packVfsImage({ + config: `- id: subject\n name: '${PLUGIN_INVENTORY}'\n`, + profile: 'plugin-inventory-check', + workspaces, + resolveFrom: repoRoot, + entries: [], +}) + /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ let archiveMemo: Uint8Array | undefined const archive = async (): Promise => @@ -191,6 +203,7 @@ const archive = async (): Promise => staticModules: createNodeBuiltins(), staticModulePrefixes: REPLACED_PREFIXES, }) + setActiveVfs(vfs) setActiveModuleLoader(loader) const landlock = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(LANDLOCK) as { LAUNCHER_BIN: string @@ -211,6 +224,59 @@ const archive = async (): Promise => expect(landlock.probe()).toBe('full') }) + it('prepares the unchanged plugin-package inventory through Worker createRequire paths', async () => { + const result = packedPluginInventory() + expect(result.missing).toEqual([]) + + const vfs = loadVfsImage(await inflateImage(result.image, 'the packed plugin inventory'), DEFAULT_ROOT) + const loader = new WorkerModuleLoader({ + vfs, + root: DEFAULT_ROOT, + staticModules: createNodeBuiltins(), + staticModulePrefixes: REPLACED_PREFIXES, + }) + setActiveVfs(vfs) + setActiveModuleLoader(loader) + const inventory = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(PLUGIN_INVENTORY) as { + apply(ctx: unknown, config: unknown): void + } + + type Prepared = { readonly value: { readonly version: number; readonly packages: readonly unknown[] } } + type Prepare = (request: { readonly body: object; readonly signal: AbortSignal }) => Promise + let prepare: Prepare | undefined + const baseUrl = `file://${DEFAULT_ROOT}/config/cordis.yml` + const tree: { readonly ctx: { readonly baseUrl: string }; entries(): readonly unknown[] } = { + ctx: { baseUrl }, + entries: () => [entry], + } + const entry = { + options: { name: PLUGIN_INVENTORY }, + disabled: false, + fiber: { state: FiberState.ACTIVE }, + parent: { tree }, + } + inventory.apply({ + baseUrl, + loader: tree, + deepseekLlmApiExtensions: { + register: (field: string, contribution: { readonly prepare: Prepare }): void => { + expect(field).toBe('dsh_plugin_packages') + prepare = contribution.prepare + }, + }, + }, {}) + + if (prepare === undefined) throw new Error('packed plugin inventory did not register its request contribution') + const prepared = await prepare({ body: {}, signal: new AbortController().signal }) + const manifest = JSON.parse(vfs.readFileSync( + `${DEFAULT_ROOT}/node_modules/${PLUGIN_INVENTORY}/package.json`, 'utf8', + ) as string) as { version: string } + expect(prepared.value).toEqual({ + version: 1, + packages: [{ name: PLUGIN_INVENTORY, version: manifest.version }], + }) + }) + it('refuses a body the packer did not lower, naming the image', async () => { // The case above only proves the packed bytes are wrappable. This is the // other half: the loader has no transform to fall back on, so an entry the diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 9b012cd360..d0d0d13a6e 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: df6dacad35273f8c636a86c7c260b4f5d1958a6a -README.zh.md: 721770149c04169d813d2b5d3d4faafdccf1dec5 +README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c +README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index df6dacad35..3e9b4fffe0 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -7,7 +7,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 721770149c..2552c659d1 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -7,7 +7,7 @@ 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。 diff --git a/packages/experimental/webworker-runtime/src/module-system/module-loader.ts b/packages/experimental/webworker-runtime/src/module-system/module-loader.ts index b1d0f70f4a..88cdc39ece 100644 --- a/packages/experimental/webworker-runtime/src/module-system/module-loader.ts +++ b/packages/experimental/webworker-runtime/src/module-system/module-loader.ts @@ -1,8 +1,8 @@ /** * CommonJS module loader over the worker VFS. It fills the `loader.internal` * seam Cordis uses for every entry import, and backs the `node:module` - * `createRequire` proxy that `typert-loader` and `client-modules` resolve - * package metadata through. + * `createRequire` proxy that `typert-loader`, `client-modules`, and the plugin + * package inventory resolve package metadata through. * * Resolution is a narrowed Node `require` algorithm: `exports` walk with a * fixed condition order, extension probing, and one cache keyed by resolved @@ -49,10 +49,26 @@ interface ModuleRecord { readonly module: { exports: unknown } } +/** Resolution helpers carried by a Worker-backed CommonJS require. */ +export interface WorkerRequireResolve { + /** + * Resolve one specifier without evaluating its module. + * @param specifier - Module request relative to the require base. + * @returns Static or VFS-backed module identity. + */ + (specifier: string): string + /** + * Return the directories this loader's Node-style package discovery searches. + * @param specifier - Module request whose lookup roots are requested. + * @returns Search roots, or null for a Worker-provided module. + */ + paths(specifier: string): string[] | null +} + /** The `require` function shape the roster consumes through `createRequire`. */ export interface WorkerRequire { (specifier: string): unknown - resolve(specifier: string): string + readonly resolve: WorkerRequireResolve } /** Construction inputs for {@link WorkerModuleLoader}. */ @@ -226,6 +242,16 @@ export class WorkerModuleLoader { return this.fail(`cannot resolve "${specifier}": no file at ${candidates.join(', ')}`) } + /** @returns The Worker-provided implementation of a static specifier. */ + private staticModule(specifier: string): StaticModuleFactory | undefined { + const exact = this.staticModules.get(specifier) + if (exact !== undefined) return exact + for (const [prefix, factory] of this.staticPrefixes) { + if (specifier.startsWith(prefix)) return factory + } + return this.staticModules.get(`node:${specifier}`) + } + /** * Resolve a specifier the way the module that requested it would. * @param specifier - Bare name, relative path, absolute path, or file URL. @@ -233,11 +259,8 @@ export class WorkerModuleLoader { * @returns Static module or the resolved VFS path. */ resolve(specifier: string, fromDirectory: string): Resolution { - const exact = this.staticModules.get(specifier) - if (exact !== undefined) return { kind: 'static', specifier, factory: exact } - for (const [prefix, factory] of this.staticPrefixes) { - if (specifier.startsWith(prefix)) return { kind: 'static', specifier, factory } - } + const staticModule = this.staticModule(specifier) + if (staticModule !== undefined) return { kind: 'static', specifier, factory: staticModule } if (specifier.startsWith('cordis:') || specifier.startsWith('node:')) { return this.fail(`no static module is registered for "${specifier}"`) } @@ -250,9 +273,6 @@ export class WorkerModuleLoader { if (isAbsolute(specifier)) { return { kind: 'file', path: this.probe(specifier, specifier) } } - // Node resolves `fs` and `node:fs` to the same builtin; the proxy table may register either. - const prefixed = this.staticModules.get(`node:${specifier}`) - if (prefixed !== undefined) return { kind: 'static', specifier, factory: prefixed } const segments = specifier.split('/') const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] ?? specifier const rest = specifier.slice(packageName.length).replace(/^\//, '') @@ -356,15 +376,20 @@ export class WorkerModuleLoader { * @returns Callable require with `resolve`. */ requireFrom(fromDirectory: string): WorkerRequire { - const require = ((specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory))) as WorkerRequire - require.resolve = (specifier: string): string => { + const require = (specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory)) + const resolve = ((specifier: string): string => { const resolution = this.resolve(specifier, fromDirectory) if (resolution.kind === 'static') { return this.fail(`"${specifier}" is a worker-provided module and has no VFS path`) } return resolution.path + }) as WorkerRequireResolve + resolve.paths = (specifier: string): string[] | null => { + if (this.staticModule(specifier) !== undefined || specifier.startsWith('node:')) return null + if (specifier.startsWith('.')) return [resolvePath(fromDirectory, '.')] + return [join(this.root, 'node_modules')] } - return require + return Object.assign(require, { resolve }) } /** diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts index bf77f12bf6..8ad91b49cd 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts @@ -1,7 +1,8 @@ /** * `node:module` for the worker: `createRequire` hands out the worker module - * loader's synchronous require, so typert's `require.resolve('/package.json') - * + readFileSync + import()` bypass runs unmodified over the VFS. + * loader's synchronous require. Typert can resolve package exports, and package + * inventory can discover manifests through `require.resolve.paths()` without + * either consumer changing for the Worker. */ import { requireActiveModuleLoader, type WorkerRequire } from '../../../module-system/module-loader.ts' @@ -11,7 +12,7 @@ export type NodeRequire = WorkerRequire /** * Build a `require` bound to a base path or file URL. * @param base - directory, file path, or file URL the resolution starts from. - * @returns the synchronous require face. + * @returns the synchronous require face, including `resolve()` and `resolve.paths()`. */ export function createRequire(base: string | URL): NodeRequire { return requireActiveModuleLoader().createRequire(base) diff --git a/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts b/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts index b302bbc382..3283f9387e 100644 --- a/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/builtins-table.spec.ts @@ -15,11 +15,11 @@ */ import { describe, expect, it } from 'vitest' import { createNodeBuiltins, REPLACED_PREFIXES } from '../../src/node/builtins.ts' -import { WorkerModuleLoader } from '../../src/module-system/module-loader.ts' +import { WorkerModuleLoader, type WorkerRequire } from '../../src/module-system/module-loader.ts' import { MemoryVfs } from '../../src/storage/memory.ts' /** A loader over an empty image: every specifier below resolves from the table. */ -function loaderRequire(): (specifier: string) => unknown { +function loaderRequire(): WorkerRequire { const vfs = new MemoryVfs() vfs.seedDirectory('/dsh') const loader = new WorkerModuleLoader({ vfs, root: '/dsh', staticModules: createNodeBuiltins() }) @@ -84,4 +84,12 @@ describe('module identity through the loader', () => { const require = loaderRequire() expect(() => require('node:dns')).toThrow() }) + + it('exposes the package search paths used by the VFS resolver', () => { + const require = loaderRequire() + expect(require.resolve.paths('node:fs')).toBeNull() + expect(require.resolve.paths('node:dns')).toBeNull() + expect(require.resolve.paths('workspace-package')).toEqual(['/dsh/node_modules']) + expect(require.resolve.paths('./local.js')).toEqual(['/dsh']) + }) }) From 5549b9add532ddf432aa52f1c9422d1eda3ff2e2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:32:10 +0800 Subject: [PATCH 18/21] fix(client): preload injected module factories --- .../client/modules/src/client/manifest.ts | 14 ++++++----- packages/client/modules/src/client/system.ts | 16 ++++++++++--- .../modules/tests/loader.client.spec.ts | 24 +++++++++++++++---- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index e5aff58f5a..ba0e521295 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -42,11 +42,10 @@ declare module '@deepseek-ai/cordis' { /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ export interface WebBootEntry { /** Entry name == package name. */ @@ -55,7 +54,7 @@ export interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean @@ -83,6 +82,8 @@ export interface BootModuleRow { url: string /** Bundle content hash. */ rev: string + /** Injected package rows whose factories arrive before this row materializes. */ + inject: string[] /** Module specifiers this row requests from the module table ([] when the wire omits them). */ external: string[] } @@ -176,6 +177,7 @@ export function parseBootManifest(wire: unknown): BootManifest { id: row.id, url: row.url, rev: row.rev, + inject: inject === undefined ? [] : [...inject], external: external === undefined ? [] : [...external], }) plugins.push({ diff --git a/packages/client/modules/src/client/system.ts b/packages/client/modules/src/client/system.ts index d4cbb5597b..4dd5311378 100644 --- a/packages/client/modules/src/client/system.ts +++ b/packages/client/modules/src/client/system.ts @@ -124,8 +124,12 @@ export class ClientModuleSystem implements ClientModuleLoader { return task } - /** Register each unresolved dynamic request before registering its consumer. */ - private async arriveGraphRow(row: BootModuleRow, open: readonly string[] = []): Promise { + /** Register each injected package and unresolved dynamic request before its consumer. */ + private async arriveGraphRow( + row: BootModuleRow, + open: readonly string[] = [], + visited = new Set(), + ): Promise { const cycleStart = open.indexOf(row.id) if (cycleStart !== -1) { throw new Error( @@ -133,12 +137,18 @@ export class ClientModuleSystem implements ClientModuleLoader { + '(the host must reject this graph before serving it)', ) } + if (visited.has(row.id)) return + visited.add(row.id) const next = [...open, row.id] for (const request of row.external) { const id = stripClientSuffix(request) if (this.seed.has(request) || this.loadCache.has(id)) continue const dependency = this.graphRows.get(id) - if (dependency !== undefined) await this.arriveGraphRow(dependency, next) + if (dependency !== undefined) await this.arriveGraphRow(dependency, next, visited) + } + for (const packageName of row.inject) { + const dependency = this.graphRows.get(packageName) + if (dependency !== undefined) await this.arriveGraphRow(dependency, [], visited) } await this.arrive(row) } diff --git a/packages/client/modules/tests/loader.client.spec.ts b/packages/client/modules/tests/loader.client.spec.ts index 747067ba37..07e83e306b 100644 --- a/packages/client/modules/tests/loader.client.spec.ts +++ b/packages/client/modules/tests/loader.client.spec.ts @@ -20,7 +20,7 @@ afterEach(() => { }) const row = (id: string, fields: Partial = {}): BootModuleRow => - ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', external: [], ...fields }) + ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', inject: [], external: [], ...fields }) interface Bench { loader: ClientModuleLoader @@ -147,6 +147,22 @@ describe('lazy CJS arrival', () => { expect(exports.react.marker).toBe('react') }) + it('registers injected package factories before materializing a consumer', async () => { + const b = bench([ + row('consumer', { inject: ['provider'] }), + row('provider', { inject: ['consumer'] }), + ], { + consumer: req => ({ provider: req('provider/client') }), + provider: () => ({ marker: 'provider' }), + }) + const exports = await b.loader.import('consumer', '', {}) as { provider: { marker: string } } + expect(b.fetched).toEqual([ + '/plugins/provider/client.js?rev=0', + '/plugins/consumer/client.js?rev=0', + ]) + expect(exports.provider.marker).toBe('provider') + }) + it('concurrent callers share one in-flight arrival and materialize once', async () => { const ran: string[] = [] const url = '/plugins/a/client.js?rev=0' @@ -306,13 +322,13 @@ describe('boot manifest wire', () => { const manifest = parseBootManifest({ rev: 'graph', entries: [ - { id: 'a', url: '/plugins/a/client.js', rev: '1' }, + { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'] }, { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] }, ], }) expect(manifest.modules).toEqual([ - { id: 'a', url: '/plugins/a/client.js', rev: '1', external: [] }, - { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] }, + { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'], external: [] }, + { id: 'b', url: '/plugins/b/client.js', rev: '2', inject: [], external: ['react'] }, ]) }) From ce1247d9531450abdf5634117440b092fb7113d5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:39:10 +0800 Subject: [PATCH 19/21] docs(client): sync injected module graph contract --- docs/subsystems/client-modules.i18n.yaml | 4 ++-- docs/subsystems/client-modules.md | 11 +++++------ docs/subsystems/client-modules.zh.md | 11 +++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 9e4e1a9dd7..de013f0be4 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -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 docs/subsystems/client-modules.md -client-modules.md: ae767c6a098cb1b7188c06e61efd2798eb93ff66 -client-modules.zh.md: 710453e3e10c298ed8c2b87bbab6e8e9eb154a87 +client-modules.md: 84d673669825495b7e22933da238daba24ec7b13 +client-modules.zh.md: 7cc353007732188f06738a745b750161a11366a5 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index ae767c6a09..84d6736698 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -14,11 +14,10 @@ The graph is the wire single source between the Node and browser halves: the hos /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ interface WebBootEntry { /** Entry name == package name. */ @@ -27,7 +26,7 @@ interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index 710453e3e1..7cc3530077 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -14,11 +14,10 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client /** * One composed client entry pushed by the host (a graph row). Wire * single source: the host node half (package root) produces this same shape. - * `immediately` marks stage-one prefetch; `inject` is informational graph - * metadata (the authoritative edges live in each package's `dsh.client` - * declaration and reach fibers through entry creation). `external` carries - * module-graph edges: unlike `inject`, they constrain code arrival because - * `require` is synchronous (see {@link WebBootGraph.entries}). + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). */ interface WebBootEntry { /** Entry name == package name. */ @@ -27,7 +26,7 @@ interface WebBootEntry { url: string /** Bundle content hash (cache-busting consistency anchor). */ rev: string - /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + /** Package-name dependency edges used for factory arrival and plugin composition. */ inject?: string[] /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ immediately?: boolean From 92cac5d291a160fff39edd67df8dad99afa4d1c2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:58:48 +0800 Subject: [PATCH 20/21] fix(webworker): close Node compatibility gaps --- .../builtin_modules/implemented/fs-watch.ts | 30 ++++-- .../node/builtin_modules/implemented/fs.ts | 47 +++++---- .../builtin_modules/implemented/stream.ts | 2 +- .../src/shell/process/landlock.ts | 9 +- .../webworker-runtime/src/storage/memory.ts | 8 ++ .../tests/node/child-process.spec.ts | 4 + .../tests/node/fs-watch-stream.spec.ts | 96 ++++++++++++++++++- .../tests/storage/memory-vfs.spec.ts | 37 +++++++ 8 files changed, 201 insertions(+), 32 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts index 61e3f4419c..a570e54bc2 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs-watch.ts @@ -354,12 +354,20 @@ export function watchAsync( let failure: Error | undefined let closed = false - const settleFailure = (reason: unknown): void => { - if (failure !== undefined || closed) return - const error = reason instanceof Error ? reason : new Error(String(reason)) - failure = error + const stopWatcher = (): void => { + options.signal?.removeEventListener('abort', onAbort) watcher?.close() - for (const pending of waiting.splice(0)) pending.reject(error) + } + const settleFailure = (reason: unknown): void => { + if (closed) return + const error = reason instanceof Error ? reason : new Error(String(reason)) + closed = true + queued.length = 0 + stopWatcher() + const failed = waiting.shift() + if (failed === undefined) failure = error + else failed.reject(error) + for (const pending of waiting.splice(0)) pending.resolve({ done: true, value: undefined }) } const onAbort = (): void => { settleFailure(abortError(options.signal?.reason)) } const start = (): void => { @@ -382,11 +390,11 @@ export function watchAsync( } } const close = (): void => { - if (closed) return + const alreadyClosed = closed closed = true queued.length = 0 - options.signal?.removeEventListener('abort', onAbort) - watcher?.close() + failure = undefined + if (!alreadyClosed) stopWatcher() for (const pending of waiting.splice(0)) pending.resolve({ done: true, value: undefined }) } @@ -396,7 +404,11 @@ export function watchAsync( }, next(): Promise> { start() - if (failure !== undefined) return Promise.reject(failure) + if (failure !== undefined) { + const reason = failure + failure = undefined + return Promise.reject(reason) + } const event = queued.shift() if (event !== undefined) return Promise.resolve({ done: false, value: event }) if (closed) return Promise.resolve({ done: true, value: undefined }) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index e4d7099f3e..b2f4462a50 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -319,14 +319,16 @@ export function openSync(path: PathArg, flags = 'r', mode?: number): number { return fd } +const badFileDescriptor = (syscall: string): never => { + const error = new Error(`EBADF: bad file descriptor, ${syscall}`) as Error & { code: string; syscall: string } + error.code = 'EBADF' + error.syscall = syscall + throw error +} + const fileOf = (fd: number, syscall: string): OpenFile => { const file = openFiles.get(fd) - if (file === undefined) { - const error = new Error(`EBADF: bad file descriptor, ${syscall}`) as Error & { code: string; syscall: string } - error.code = 'EBADF' - error.syscall = syscall - throw error - } + if (file === undefined) return badFileDescriptor(syscall) return file } @@ -558,15 +560,18 @@ export class ReadStream extends Readable { callback(abortError(this.signal.reason)) return } + let fd: number try { - this.fd = openSync(this.path, this.flags) - this.pending = false - this.emit('open', this.fd) - this.emit('ready') - callback() + fd = openSync(this.path, this.flags) } catch (error) { callback(error as Error) + return } + this.fd = fd + this.pending = false + callback() + this.emit('open', fd) + this.emit('ready') } override _read(size: number): void { @@ -648,16 +653,19 @@ export class WriteStream extends Writable { callback(abortError(this.signal.reason)) return } + let fd: number try { - this.fd = openSync(this.path, this.flags, this.mode) - if (this.start !== undefined) fileOf(this.fd, 'write').position = this.start - this.pending = false - this.emit('open', this.fd) - this.emit('ready') - callback() + fd = openSync(this.path, this.flags, this.mode) } catch (error) { callback(error as Error) + return } + this.fd = fd + if (this.start !== undefined) fileOf(fd, 'write').position = this.start + this.pending = false + callback() + this.emit('open', fd) + this.emit('ready') } override _write( @@ -666,9 +674,10 @@ export class WriteStream extends Writable { callback: (error?: Error | null) => void, ): void { try { - if (this.fd === null) throw new Error('EBADF: bad file descriptor, write') + const fd = this.fd + if (fd === null) return badFileDescriptor('write') const data = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk - this.bytesWritten += writeSync(this.fd, data) + this.bytesWritten += writeSync(fd, data) callback() } catch (error) { callback(error as Error) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts index a7fca23540..19c60afa15 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/stream.ts @@ -46,7 +46,7 @@ if (getDefaultHighWaterMark(false) !== 64 * 1024) setDefaultHighWaterMark(false, const _isArrayBufferView = (value: unknown): value is ArrayBufferView => ArrayBuffer.isView(value) /** Default-import namespace carrying Node's stream class and static helpers. */ -const streamDefault = Object.assign(Stream, { +const streamDefault = Object.assign(StreamBase, { _isArrayBufferView, getDefaultHighWaterMark, isDestroyed, diff --git a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts index 03ff455c64..5d8c8c3ca0 100644 --- a/packages/experimental/webworker-runtime/src/shell/process/landlock.ts +++ b/packages/experimental/webworker-runtime/src/shell/process/landlock.ts @@ -108,13 +108,18 @@ export async function landlockFileSystem( const readWrite = await Promise.all(invocation.readWrite.map(normalizeGrant)) const readable = [...readOnly, ...readWrite] - const readPath = (path: string, syscall: string): string => { + const checkedPath = (path: string, syscall: string): string => { const target = vfsPath(path, cwd) + if (target.startsWith(`${NULL_PATH}/`)) throw filesystemError('ENOTDIR', syscall, path) + return target + } + const readPath = (path: string, syscall: string): string => { + const target = checkedPath(path, syscall) if (!readable.some(root => contains(root, target))) deny(syscall, path) return target } const writePath = (path: string, syscall: string): string => { - const target = vfsPath(path, cwd) + const target = checkedPath(path, syscall) if (!readWrite.some(root => contains(root, target))) deny(syscall, path) return target } diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 7502e90bb8..5a6826f2e3 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -699,6 +699,14 @@ export class MemoryVfs implements Vfs { return } if (!this.directories.has(source)) fail('ENOENT', 'rename', source) + if (this.files.has(destination)) fail('ENOTDIR', 'rename', destination) + if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination) + if (this.directories.has(destination)) { + if (this.readdirSync(destination).length > 0) fail('ENOTEMPTY', 'rename', destination) + this.directories.delete(destination) + this.directoryModes.delete(destination) + this.directoryMtimes.delete(destination) + } const prefix = `${source}${SEP}` const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = [] for (const [candidate, value] of [...this.files]) { diff --git a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts index 3c5304e8df..a1e6fd3e9c 100644 --- a/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/child-process.spec.ts @@ -160,6 +160,10 @@ it('enforces every ShellFileSystem operation and virtual device edge', async () await expect(guarded.mkdir('/dev/null', false)).rejects.toMatchObject({ code: 'EEXIST' }) await expect(guarded.remove('/dev/null', { recursive: false, force: false })).rejects.toMatchObject({ code: 'EACCES' }) await expect(guarded.rename('/dev/null', `${WORKSPACE}/null`)).rejects.toMatchObject({ code: 'EACCES' }) + await expect(guarded.stat('/dev/null/child')).rejects.toMatchObject({ code: 'ENOTDIR' }) + await expect(guarded.writeText('/dev/null/child', 'not written')).rejects.toMatchObject({ code: 'ENOTDIR' }) + await expect(guarded.mkdir('/dev/null/child', true)).rejects.toMatchObject({ code: 'ENOTDIR' }) + expect(vfs.existsSync('/dev')).toBe(false) await expect(guarded.readText(`${HOME}/private.txt`)).rejects.toMatchObject({ code: 'EACCES' }) await guarded.mkdir('created', false) diff --git a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts index df8c31e0b5..de84fcce85 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs-watch-stream.spec.ts @@ -273,6 +273,9 @@ describe('file streams', () => { const values: string[] = [] for await (const value of workerStream.Readable.from(['one', 'two'])) values.push(String(value)) expect(values).toEqual(['one', 'two']) + expect(workerStream.default).toBe(workerStream.Stream) + expect(new workerStream.Writable({ write: (_chunk, _encoding, callback) => { callback() } })) + .toBeInstanceOf(workerStream.default) expect(typeof workerStream.pipeline).toBe('function') expect(typeof workerStream.finished).toBe('function') expect(workerStream.getDefaultHighWaterMark(false)).toBe(64 * 1024) @@ -385,6 +388,91 @@ describe('file streams', () => { }) expect(events).toEqual(['error', 'close']) }) + + it('publishes descriptors before open and ready listener exceptions escape', () => { + const readPath = `${VFS_ROOT}/listener-read.txt` + vfs.writeFileSync(readPath, 'content') + const readCallback = vi.fn() + const readFailure = new Error('read open listener failed') + const readReceiver: { + path: string + flags: string + start: number + end: number + signal: undefined + pending: boolean + fd: number | null + emit(event: string): boolean + } = { + path: readPath, + flags: 'r', + start: 0, + end: Number.POSITIVE_INFINITY, + signal: undefined, + pending: true, + fd: null, + emit(event) { + expect(readCallback).toHaveBeenCalledOnce() + if (event === 'open') throw readFailure + return true + }, + } + expect(() => { + workerFs.ReadStream.prototype._construct.call( + readReceiver as unknown as workerFs.ReadStream, + readCallback, + ) + }).toThrow(readFailure) + expect(readReceiver.pending).toBe(false) + expect(readReceiver.fd).not.toBeNull() + workerFs.closeSync(readReceiver.fd as number) + + const writeCallback = vi.fn() + const writeFailure = new Error('write ready listener failed') + const writeReceiver: { + path: string + flags: string + mode: undefined + start: undefined + signal: undefined + pending: boolean + fd: number | null + emit(event: string): boolean + } = { + path: `${VFS_ROOT}/listener-write.txt`, + flags: 'w', + mode: undefined, + start: undefined, + signal: undefined, + pending: true, + fd: null, + emit(event) { + expect(writeCallback).toHaveBeenCalledOnce() + if (event === 'ready') throw writeFailure + return true + }, + } + expect(() => { + workerFs.WriteStream.prototype._construct.call( + writeReceiver as unknown as workerFs.WriteStream, + writeCallback, + ) + }).toThrow(writeFailure) + expect(writeReceiver.pending).toBe(false) + expect(writeReceiver.fd).not.toBeNull() + workerFs.closeSync(writeReceiver.fd as number) + }) + + it('codes a write before descriptor publication as EBADF', () => { + let failure: Error | null | undefined + workerFs.WriteStream.prototype._write.call( + { fd: null } as unknown as workerFs.WriteStream, + Buffer.from('x'), + 'utf8', + (error) => { failure = error }, + ) + expect(failure).toMatchObject({ code: 'EBADF', syscall: 'write' }) + }) }) interface StatTransition { @@ -673,8 +761,12 @@ describe('watchers', () => { const event = iterator.next() vfs.writeFileSync(`${VFS_ROOT}/async.txt`, 'x') await expect(event).resolves.toEqual({ done: false, value: { eventType: 'rename', filename: 'async.txt' } }) + const failed = iterator.next() + const completed = iterator.next() controller.abort() - await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + await expect(failed).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR' }) + await expect(completed).resolves.toEqual({ done: true, value: undefined }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) it('rejects the first promise-watch read for a pre-aborted signal', async () => { @@ -683,6 +775,7 @@ describe('watchers', () => { controller.abort(reason) const iterator = workerFsp.watch(VFS_ROOT, { signal: controller.signal })[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ name: 'AbortError', code: 'ABORT_ERR', cause: reason }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) it('lets promise-watch return interrupt a pending next call', async () => { @@ -697,6 +790,7 @@ describe('watchers', () => { it('propagates promise-watch startup and throw failures', async () => { const missing = workerFsp.watch(`${VFS_ROOT}/missing`)[Symbol.asyncIterator]() await expect(missing.next()).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(missing.next()).resolves.toEqual({ done: true, value: undefined }) const iterator = workerFsp.watch(VFS_ROOT)[Symbol.asyncIterator]() const reason = { reason: 'caller stopped iteration' } diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 069da0ca2a..ac994e84aa 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -220,6 +220,43 @@ describe('mutation publication', () => { }) }) +describe('directory rename', () => { + it('rejects file, non-empty directory, and missing-parent destinations before mutation', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/source/nested/file', 'source') + vfs.seed('/dsh/file', 'destination') + vfs.seed('/dsh/non-empty/child', 'destination') + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + + expect(() => { vfs.renameSync('/dsh/source', '/dsh/file') }) + .toThrow(expect.objectContaining({ code: 'ENOTDIR' })) + expect(() => { vfs.renameSync('/dsh/source', '/dsh/non-empty') }) + .toThrow(expect.objectContaining({ code: 'ENOTEMPTY' })) + expect(() => { vfs.renameSync('/dsh/source', '/missing/destination') }) + .toThrow(expect.objectContaining({ code: 'ENOENT' })) + + expect(vfs.readFileSync('/dsh/source/nested/file', 'utf8')).toBe('source') + expect(vfs.readFileSync('/dsh/file', 'utf8')).toBe('destination') + expect(vfs.readFileSync('/dsh/non-empty/child', 'utf8')).toBe('destination') + expect(mutations).toEqual([]) + }) + + it('replaces an empty directory with the source subtree', () => { + const vfs = new MemoryVfs() + vfs.seedDirectory('/dsh/source/nested', { mode: 0o700 }) + vfs.seed('/dsh/source/nested/file', 'source') + vfs.seedDirectory('/dsh/destination', { mode: 0o711 }) + + vfs.renameSync('/dsh/source', '/dsh/destination') + + expect(vfs.existsSync('/dsh/source')).toBe(false) + expect(vfs.readFileSync('/dsh/destination/nested/file', 'utf8')).toBe('source') + expect((vfs.statSync('/dsh/destination') as VfsStats).mode & 0o777).toBe(0o755) + expect((vfs.statSync('/dsh/destination/nested') as VfsStats).mode & 0o777).toBe(0o700) + }) +}) + describe('hard links', () => { it('shares identity, bytes, and mode until one name is removed', () => { const vfs = new MemoryVfs() From ab0f7937caa4f1006fbd1efce6f49e81c86650e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:33:51 +0800 Subject: [PATCH 21/21] perf(webworker): index VFS hard links --- .../webworker-runtime/src/storage/memory.ts | 103 ++++++++++++++---- .../tests/storage/memory-vfs.spec.ts | 62 ++++++++++- 2 files changed, 140 insertions(+), 25 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 5a6826f2e3..24d12a688e 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -22,6 +22,8 @@ interface FileNode { mode: number /** Stable identity shared by hard links and retained by open descriptors. */ identity?: bigint + /** One path normally, a Set only for hard links, or undefined after the final unlink. */ + paths: string | Set | undefined } /** Creation default for files, Node's `0o666` under the classic `022` umask. */ @@ -309,7 +311,7 @@ export class MemoryVfs implements Vfs { : fail('ENOENT', 'stat', target) const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node) return options?.bigint === true - ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.pathsOf(node).length) + ? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.fileLinkCount(node)) : statsOf(size, mtimeMs, directory, identity, mode) } @@ -335,23 +337,70 @@ export class MemoryVfs implements Vfs { return node.identity } - /** @returns Every currently linked path for one file node. */ - private pathsOf(node: FileNode): string[] { - const paths: string[] = [] - for (const [path, candidate] of this.files) { - if (candidate === node) paths.push(path) + /** @returns The number of names currently linked to one file node. */ + private fileLinkCount(node: FileNode): number { + return typeof node.paths === 'string' ? 1 : node.paths?.size ?? 0 + } + + /** Add one map name, promoting the rare hard-link case to a Set. */ + private addFilePath(node: FileNode, path: string): void { + if (node.paths === undefined) { + node.paths = path + } else if (typeof node.paths === 'string') { + node.paths = new Set([node.paths, path]) + } else { + node.paths.add(path) } - return paths + } + + /** Remove one map name, collapsing a remaining single link back to a string. */ + private removeFilePath(node: FileNode, path: string): void { + if (typeof node.paths === 'string') { + node.paths = undefined + return + } + if (node.paths === undefined) return + node.paths.delete(path) + if (node.paths.size === 1) { + const [remaining] = node.paths + node.paths = remaining + } + } + + /** Set one file-map entry while maintaining both nodes' reverse path indexes. */ + private setFile(path: string, node: FileNode): void { + const previous = this.files.get(path) + if (previous === node) return + if (previous !== undefined) this.removeFilePath(previous, path) + this.files.set(path, node) + this.addFilePath(node, path) + } + + /** Delete one file-map entry while retaining an unlinked node held by a descriptor. */ + private deleteFile(path: string): FileNode | undefined { + const node = this.files.get(path) + if (node === undefined) return undefined + this.files.delete(path) + this.removeFilePath(node, path) + return node + } + + /** Publish one linked name after a content or metadata write. */ + private publishFilePath(node: FileNode, path: string, appendedFrom?: number): void { + this.publish({ + kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false, + ...appendedFrom === undefined ? {} : { appendedFrom }, + }) } /** Publish a content or metadata write for every hard link to one node. */ private publishFile(node: FileNode, appendedFrom?: number): void { - for (const path of this.pathsOf(node)) { - this.publish({ - kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false, - ...appendedFrom === undefined ? {} : { appendedFrom }, - }) + if (typeof node.paths === 'string') { + this.publishFilePath(node, node.paths, appendedFrom) + return } + if (node.paths === undefined) return + for (const path of node.paths) this.publishFilePath(node, path, appendedFrom) } /** Replace bytes on one file identity and notify all linked paths. */ @@ -517,8 +566,8 @@ export class MemoryVfs implements Vfs { this.replaceFile(previous, bytes) return } - const node: FileNode = { bytes, mtimeMs: this.touch(target), mode } - this.files.set(target, node) + const node: FileNode = { bytes, mtimeMs: this.touch(target), mode, paths: undefined } + this.setFile(target, node) this.touchDirectory(dirname(target)) this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true }) } @@ -688,8 +737,9 @@ export class MemoryVfs implements Vfs { if (node !== undefined) { if (this.directories.has(destination)) fail('EISDIR', 'rename', destination) if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination) - this.files.delete(source) - this.files.set(destination, node) + if (this.files.get(destination) === node) return + this.deleteFile(source) + this.setFile(destination, node) this.forgetIdentity(source) this.forgetIdentity(destination) this.touchDirectory(dirname(source)) @@ -711,9 +761,9 @@ export class MemoryVfs implements Vfs { const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = [] for (const [candidate, value] of [...this.files]) { if (!candidate.startsWith(prefix)) continue - this.files.delete(candidate) + this.deleteFile(candidate) const target = join(destination, candidate.slice(prefix.length)) - this.files.set(target, value) + this.setFile(target, value) movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode }) } const movedDirectories: Array<{ path: string; mode: number }> = [] @@ -760,7 +810,7 @@ export class MemoryVfs implements Vfs { if (node === undefined) fail('ENOENT', 'link', source) if (this.files.has(target) || this.directories.has(target)) fail('EEXIST', 'link', target) if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target) - this.files.set(target, node) + this.setFile(target, node) this.touchDirectory(dirname(target)) this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true }) } @@ -787,7 +837,11 @@ export class MemoryVfs implements Vfs { const node = this.files.get(target) if (node !== undefined) { node.mode = mode & 0o777 - for (const path of this.pathsOf(node)) this.publish({ kind: 'chmod', path, mode: node.mode }) + if (typeof node.paths === 'string') { + this.publish({ kind: 'chmod', path: node.paths, mode: node.mode }) + } else if (node.paths !== undefined) { + for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode }) + } return } if (this.directories.has(target)) { @@ -805,7 +859,7 @@ export class MemoryVfs implements Vfs { */ unlinkSync(path: string): void { const target = this.key(path) - if (!this.files.delete(target)) fail('ENOENT', 'unlink', target) + if (this.deleteFile(target) === undefined) fail('ENOENT', 'unlink', target) this.forgetIdentity(target) this.touchDirectory(dirname(target)) this.publish({ kind: 'remove', path: target }) @@ -818,7 +872,7 @@ export class MemoryVfs implements Vfs { */ rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void { const target = this.key(path) - if (this.files.delete(target)) { + if (this.deleteFile(target) !== undefined) { this.forgetIdentity(target) this.touchDirectory(dirname(target)) this.publish({ kind: 'remove', path: target }) @@ -827,7 +881,7 @@ export class MemoryVfs implements Vfs { if (this.directories.has(target)) { if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target) const prefix = `${target}${SEP}` - for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.files.delete(candidate) + for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.deleteFile(candidate) for (const candidate of [...this.directories]) { if (!candidate.startsWith(prefix)) continue this.directories.delete(candidate) @@ -866,10 +920,11 @@ export class MemoryVfs implements Vfs { seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void { const target = this.key(path) this.seedDirectory(dirname(target)) - this.files.set(target, { + this.setFile(target, { bytes: typeof data === 'string' ? encoder.encode(data) : data, mtimeMs: options.mtimeMs ?? this.touch(target), mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777, + paths: undefined, }) this.touchDirectory(dirname(target)) } diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index ac994e84aa..b80987cd4c 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -17,6 +17,9 @@ import type { VfsBigIntStats, VfsMutation, VfsMutationSink, VfsStats } from '../ const identity = (vfs: MemoryVfs, path: string): bigint => (vfs.statSync(path, { bigint: true }) as VfsBigIntStats).ino +const linkCount = (vfs: MemoryVfs, path: string): bigint => + (vfs.statSync(path, { bigint: true }) as VfsBigIntStats).nlink + const modified = (vfs: MemoryVfs, path: string): number => (vfs.statSync(path) as VfsStats).mtimeMs afterEach(() => { vi.restoreAllMocks() }) @@ -262,20 +265,77 @@ describe('hard links', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/session.jsonl', 'committed\n') vfs.linkSync('/dsh/session.jsonl', '/dsh/session-latest.jsonl') + vfs.linkSync('/dsh/session-latest.jsonl', '/dsh/session-archive.jsonl') expect(identity(vfs, '/dsh/session-latest.jsonl')).toBe(identity(vfs, '/dsh/session.jsonl')) + expect(linkCount(vfs, '/dsh/session.jsonl')).toBe(3n) expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\n') const changedPaths: string[] = [] vfs.subscribe((mutation) => { changedPaths.push(mutation.path) }) vfs.appendFileSync('/dsh/session.jsonl', 'appended\n') - expect(changedPaths).toEqual(['/dsh/session.jsonl', '/dsh/session-latest.jsonl']) + expect(changedPaths).toEqual([ + '/dsh/session.jsonl', + '/dsh/session-latest.jsonl', + '/dsh/session-archive.jsonl', + ]) expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') expect(vfs.readFileSync('/dsh/session-latest.jsonl', 'utf8')).toBe('committed\nappended\n') vfs.chmodSync('/dsh/session-latest.jsonl', 0o600) expect((vfs.statSync('/dsh/session.jsonl') as VfsStats).mode & 0o777).toBe(0o600) vfs.unlinkSync('/dsh/session-latest.jsonl') + expect(linkCount(vfs, '/dsh/session.jsonl')).toBe(2n) + vfs.unlinkSync('/dsh/session-archive.jsonl') + expect(linkCount(vfs, '/dsh/session.jsonl')).toBe(1n) expect(vfs.readFileSync('/dsh/session.jsonl', 'utf8')).toBe('committed\nappended\n') }) + it('treats rename between names of the same node as a no-op', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/source', 'value') + vfs.linkSync('/dsh/source', '/dsh/alias') + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + + vfs.renameSync('/dsh/source', '/dsh/alias') + + expect(vfs.readFileSync('/dsh/source', 'utf8')).toBe('value') + expect(vfs.readFileSync('/dsh/alias', 'utf8')).toBe('value') + expect(linkCount(vfs, '/dsh/source')).toBe(2n) + expect(mutations).toEqual([]) + }) + + it('retargets linked names through file replacement and directory moves', () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/replacement', 'replacement') + vfs.seed('/dsh/target', 'old') + vfs.linkSync('/dsh/target', '/dsh/target-alias') + const replaced = vfs.openFileSync('/dsh/target', 'r+') + vfs.renameSync('/dsh/replacement', '/dsh/target') + const mutations: VfsMutation[] = [] + vfs.subscribe((mutation) => { mutations.push(mutation) }) + + replaced.write(0, new TextEncoder().encode('changed')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/target-alias']) + expect(vfs.readFileSync('/dsh/target', 'utf8')).toBe('replacement') + expect(vfs.readFileSync('/dsh/target-alias', 'utf8')).toBe('changed') + expect(linkCount(vfs, '/dsh/target-alias')).toBe(1n) + + vfs.seed('/dsh/tree/file', 'tree') + vfs.linkSync('/dsh/tree/file', '/dsh/outside') + const moved = vfs.openFileSync('/dsh/tree/file', 'r+') + vfs.renameSync('/dsh/tree', '/dsh/moved') + mutations.length = 0 + moved.write(0, new TextEncoder().encode('moved')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/outside', '/dsh/moved/file']) + expect(linkCount(vfs, '/dsh/moved/file')).toBe(2n) + + vfs.rmSync('/dsh/moved', { recursive: true }) + mutations.length = 0 + moved.write(0, new TextEncoder().encode('kept!')) + expect(mutations.map(mutation => mutation.path)).toEqual(['/dsh/outside']) + expect(vfs.readFileSync('/dsh/outside', 'utf8')).toBe('kept!') + expect(linkCount(vfs, '/dsh/outside')).toBe(1n) + }) + it('rejects renaming a file over an existing directory', () => { const vfs = new MemoryVfs() vfs.seed('/dsh/file', 'value')